diff --git a/src/hooks/runtime-fallback/first-prompt-watchdog.test.ts b/src/hooks/runtime-fallback/first-prompt-watchdog.test.ts index 98767bf65..84d62510d 100644 --- a/src/hooks/runtime-fallback/first-prompt-watchdog.test.ts +++ b/src/hooks/runtime-fallback/first-prompt-watchdog.test.ts @@ -149,6 +149,70 @@ describe("first-prompt-watchdog", () => { watchdog.dispose() }) + it("#given session emits message.part.updated with sessionID under properties.part #when watchdog tracks #then the watchdog recognizes progress and resets the silence timer", async () => { + // given + const sessionID = "session-nested-part-progress" + subagentSessions.add(sessionID) + const deps = createDeps(PLUGIN_CONFIG_WITH_FALLBACK) + const calls: RecordedCalls = { abort: [], autoRetry: [] } + const helpers = createHelpers(calls, AGENT) + const watchdog = createFirstPromptWatchdog(deps, helpers, WATCHDOG_MS) + + // when + watchdog.onUserMessage(sessionID, PRIMARY_MODEL, AGENT) + await wait(SAFE_WAIT_BEFORE_FIRE_MS) + observeEventForWatchdog( + { + type: "message.part.updated", + properties: { + part: { + id: "part-1", + messageID: "msg-1", + sessionID, + type: "text", + text: "still working", + }, + }, + }, + watchdog, + ) + await wait(SAFE_WAIT_AFTER_FIRE_MS) + + // then + expect(calls.abort).toEqual([]) + expect(calls.autoRetry).toEqual([]) + + watchdog.dispose() + }) + + it("#given session emits message.part.delta with field/delta but no part.type #when watchdog tracks #then the watchdog recognizes progress", async () => { + // given + const sessionID = "session-delta-progress" + subagentSessions.add(sessionID) + const deps = createDeps(PLUGIN_CONFIG_WITH_FALLBACK) + const calls: RecordedCalls = { abort: [], autoRetry: [] } + const helpers = createHelpers(calls, AGENT) + const watchdog = createFirstPromptWatchdog(deps, helpers, WATCHDOG_MS) + + // when + watchdog.onUserMessage(sessionID, PRIMARY_MODEL, AGENT) + await wait(SAFE_WAIT_BEFORE_FIRE_MS) + observeEventForWatchdog( + { + type: "message.part.delta", + properties: { sessionID, field: "text", delta: "x" }, + }, + watchdog, + ) + await wait(SAFE_WAIT_AFTER_FIRE_MS) + + // then + expect(calls.abort).toEqual([]) + expect(calls.autoRetry).toEqual([]) + + watchdog.dispose() + }) + it("#given the session is not a subagent #when a user message is observed #then the watchdog never arms and nothing fires", async () => { // given const sessionID = "session-not-a-subagent" diff --git a/src/hooks/shared/prompt-async-gate.test.ts b/src/hooks/shared/prompt-async-gate.test.ts index d3e526b56..8b0c79705 100644 --- a/src/hooks/shared/prompt-async-gate.test.ts +++ b/src/hooks/shared/prompt-async-gate.test.ts @@ -751,6 +751,86 @@ describe("dispatchInternalPrompt shared gate behavior", () => { expect(promptCalls).toBe(0) }) + test("#given latest assistant turn has boolean finish === true #when checking blocks #then it is treated as terminal (NOT blocking)", async () => { + // given + let promptCalls = 0 + const client = { + session: { + status: async () => ({ data: { ses_boolean_finish: { type: "idle" } } }), + messages: async () => ({ + data: [ + { + info: { id: "msg_user", role: "user" }, + parts: [{ type: "text", text: "run work" }], + }, + { + info: { id: "msg_assistant", role: "assistant", finish: true }, + parts: [{ type: "text", text: "done" }], + }, + ], + }), + promptAsync: async () => { + promptCalls += 1 + }, + }, + } + + // when + const result = await dispatchInternalPrompt({ + mode: "async", + client, + sessionID: "ses_boolean_finish", + input: { path: { id: "ses_boolean_finish" }, body: { parts: [] } }, + source: "test:boolean-finish", + settleMs: 0, + postDispatchHoldMs: 0, + }) + + // then + expect(result.status).toBe("dispatched") + expect(promptCalls).toBe(1) + }) + + test("#given latest assistant turn has info.time.completed but no finish field #when checking blocks #then it is treated as terminal", async () => { + // given + let promptCalls = 0 + const client = { + session: { + status: async () => ({ data: { ses_completed_time: { type: "idle" } } }), + messages: async () => ({ + data: [ + { + info: { id: "msg_user", role: "user" }, + parts: [{ type: "text", text: "run work" }], + }, + { + info: { id: "msg_assistant", role: "assistant", time: { completed: 1_762_000_000_000 } }, + parts: [{ type: "text", text: "done" }], + }, + ], + }), + promptAsync: async () => { + promptCalls += 1 + }, + }, + } + + // when + const result = await dispatchInternalPrompt({ + mode: "async", + client, + sessionID: "ses_completed_time", + input: { path: { id: "ses_completed_time" }, body: { parts: [] } }, + source: "test:completed-time", + settleMs: 0, + postDispatchHoldMs: 0, + }) + + // then + expect(result.status).toBe("dispatched") + expect(promptCalls).toBe(1) + }) + test("#given internal user tail follows an assistant waiting on tools #when an internal promptAsync is requested #then no prompt is sent", async () => { // given let promptCalls = 0