fix(typing): keep tool-start ttl mode-safe (#452, thanks @thesash)

This commit is contained in:
Peter Steinberger
2026-01-08 06:18:11 +00:00
parent 29c5ed54b2
commit 6a81652ebf
6 changed files with 45 additions and 4 deletions

View File

@@ -19,6 +19,7 @@
- CLI: remove `update`, `gateway-daemon`, `gateway {install|uninstall|start|stop|restart|daemon status|wake|send|agent}`, and `telegram` commands; use `daemon` for service control, `send`/`agent`/`wake` for RPC, and `nodes canvas` for canvas ops. - CLI: remove `update`, `gateway-daemon`, `gateway {install|uninstall|start|stop|restart|daemon status|wake|send|agent}`, and `telegram` commands; use `daemon` for service control, `send`/`agent`/`wake` for RPC, and `nodes canvas` for canvas ops.
### Fixes ### Fixes
- Auto-reply: keep typing indicators alive during tool execution without changing typing-mode semantics. Thanks @thesash for PR #452.
- macOS: harden Voice Wake tester/runtime (pause trigger, mic persistence, local-only tester) and keep transcript logs private. Thanks @xadenryan for PR #438. - macOS: harden Voice Wake tester/runtime (pause trigger, mic persistence, local-only tester) and keep transcript logs private. Thanks @xadenryan for PR #438.
- macOS: preserve node bridge tunnel port override so remote nodes connect on the bridge port. Thanks @sircrumpet for PR #364. - macOS: preserve node bridge tunnel port override so remote nodes connect on the bridge port. Thanks @sircrumpet for PR #364.
- Doctor/Daemon: surface gateway runtime state + port collision diagnostics; warn on legacy workspace dirs. - Doctor/Daemon: surface gateway runtime state + port collision diagnostics; warn on legacy workspace dirs.

View File

@@ -2,14 +2,18 @@ import { vi } from "vitest";
import type { TypingController } from "./typing.js"; import type { TypingController } from "./typing.js";
export function createMockTypingController(): TypingController { export function createMockTypingController(
overrides: Partial<TypingController> = {},
): TypingController {
return { return {
onReplyStart: vi.fn(async () => {}), onReplyStart: vi.fn(async () => {}),
startTypingLoop: vi.fn(async () => {}), startTypingLoop: vi.fn(async () => {}),
startTypingOnText: vi.fn(async () => {}), startTypingOnText: vi.fn(async () => {}),
refreshTypingTtl: vi.fn(), refreshTypingTtl: vi.fn(),
isActive: vi.fn(() => false),
markRunComplete: vi.fn(), markRunComplete: vi.fn(),
markDispatchIdle: vi.fn(), markDispatchIdle: vi.fn(),
cleanup: vi.fn(), cleanup: vi.fn(),
...overrides,
}; };
} }

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { createMockTypingController } from "./test-helpers.js"; import { createMockTypingController } from "./test-helpers.js";
import { createTypingSignaler, resolveTypingMode } from "./typing-mode.js"; import { createTypingSignaler, resolveTypingMode } from "./typing-mode.js";
@@ -123,6 +123,36 @@ describe("createTypingSignaler", () => {
expect(typing.startTypingOnText).not.toHaveBeenCalled(); expect(typing.startTypingOnText).not.toHaveBeenCalled();
}); });
it("does not start typing on tool start when inactive", async () => {
const typing = createMockTypingController();
const signaler = createTypingSignaler({
typing,
mode: "message",
isHeartbeat: false,
});
await signaler.signalToolStart();
expect(typing.refreshTypingTtl).not.toHaveBeenCalled();
expect(typing.startTypingLoop).not.toHaveBeenCalled();
});
it("refreshes ttl on tool start when active", async () => {
const typing = createMockTypingController({
isActive: vi.fn(() => true),
});
const signaler = createTypingSignaler({
typing,
mode: "message",
isHeartbeat: false,
});
await signaler.signalToolStart();
expect(typing.refreshTypingTtl).toHaveBeenCalled();
expect(typing.startTypingLoop).not.toHaveBeenCalled();
});
it("suppresses typing when disabled", async () => { it("suppresses typing when disabled", async () => {
const typing = createMockTypingController(); const typing = createMockTypingController();
const signaler = createTypingSignaler({ const signaler = createTypingSignaler({

View File

@@ -68,8 +68,9 @@ export function createTypingSignaler(params: {
const signalToolStart = async () => { const signalToolStart = async () => {
if (disabled) return; if (disabled) return;
// Keep typing indicator alive during tool execution if (!typing.isActive()) return;
await typing.startTypingLoop(); // Keep typing indicator alive during tool execution without changing mode semantics.
typing.refreshTypingTtl();
}; };
return { return {

View File

@@ -3,6 +3,7 @@ export type TypingController = {
startTypingLoop: () => Promise<void>; startTypingLoop: () => Promise<void>;
startTypingOnText: (text?: string) => Promise<void>; startTypingOnText: (text?: string) => Promise<void>;
refreshTypingTtl: () => void; refreshTypingTtl: () => void;
isActive: () => boolean;
markRunComplete: () => void; markRunComplete: () => void;
markDispatchIdle: () => void; markDispatchIdle: () => void;
cleanup: () => void; cleanup: () => void;
@@ -76,6 +77,8 @@ export function createTypingController(params: {
}, typingTtlMs); }, typingTtlMs);
}; };
const isActive = () => active && !sealed;
const triggerTyping = async () => { const triggerTyping = async () => {
if (sealed) return; if (sealed) return;
await onReplyStart?.(); await onReplyStart?.();
@@ -138,6 +141,7 @@ export function createTypingController(params: {
startTypingLoop, startTypingLoop,
startTypingOnText, startTypingOnText,
refreshTypingTtl, refreshTypingTtl,
isActive,
markRunComplete, markRunComplete,
markDispatchIdle, markDispatchIdle,
cleanup, cleanup,

View File

@@ -330,6 +330,7 @@ describe("typing controller idle", () => {
startTypingLoop: vi.fn(async () => {}), startTypingLoop: vi.fn(async () => {}),
startTypingOnText: vi.fn(async () => {}), startTypingOnText: vi.fn(async () => {}),
refreshTypingTtl: vi.fn(), refreshTypingTtl: vi.fn(),
isActive: vi.fn(() => false),
markRunComplete: vi.fn(), markRunComplete: vi.fn(),
markDispatchIdle, markDispatchIdle,
cleanup: vi.fn(), cleanup: vi.fn(),