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/runtime-fallback/first-prompt-watchdog.ts b/src/hooks/runtime-fallback/first-prompt-watchdog.ts index 1af473d40..7ee8f2265 100644 --- a/src/hooks/runtime-fallback/first-prompt-watchdog.ts +++ b/src/hooks/runtime-fallback/first-prompt-watchdog.ts @@ -3,6 +3,8 @@ import type { AutoRetryHelpers } from "./auto-retry" import { HOOK_NAME, DEFAULT_FIRST_PROMPT_WATCHDOG_MS } from "./constants" import { log } from "../../shared/logger" import { subagentSessions } from "../../features/claude-code-session-state" +import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id" +import { isRecord } from "../../shared/record-type-guard" import { createFallbackState } from "./fallback-state" import { getFallbackModelsForSession } from "./fallback-models" import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model" @@ -43,25 +45,26 @@ export function observeEventForWatchdog( event: { type: string; properties?: unknown }, watchdog: FirstPromptWatchdog, ): void { - const props = event.properties as Record | undefined + const props = isRecord(event.properties) ? event.properties : undefined if (!props) return if (event.type === "message.part.updated" || event.type === "message.part.delta") { - const sessionID = props.sessionID as string | undefined - const part = props.part as Record | undefined - const partType = typeof part?.type === "string" - ? part.type - : typeof props.type === "string" ? props.type : undefined - if (sessionID && partType) { + const sessionID = resolveMessageEventSessionID(props) + const part = isRecord(props.part) ? props.part : undefined + const hasPartType = typeof part?.type === "string" + const hasTopLevelType = typeof props.type === "string" + const hasTextDelta = props.field === "text" && typeof props.delta === "string" + const hasNonEmptySessionPart = typeof part?.sessionID === "string" && Object.keys(part).length > 0 + if (sessionID && (hasPartType || hasTopLevelType || hasTextDelta || hasNonEmptySessionPart)) { watchdog.onAssistantProgress(sessionID) } return } if (event.type === "message.updated") { - const info = props.info as Record | undefined - const sessionID = info?.sessionID as string | undefined - const role = info?.role as string | undefined + const info = isRecord(props.info) ? props.info : undefined + const sessionID = typeof info?.sessionID === "string" ? info.sessionID : undefined + const role = typeof info?.role === "string" ? info.role : undefined if (!sessionID || !role) return if (role === "user") { @@ -74,10 +77,10 @@ export function observeEventForWatchdog( if (role === "assistant") { const hasError = info?.error !== undefined const hasFinish = info?.finish !== undefined - const eventParts = props.parts as Array<{ type?: string }> | undefined - const infoParts = info?.parts as Array<{ type?: string }> | undefined + const eventParts = Array.isArray(props.parts) ? props.parts : undefined + const infoParts = Array.isArray(info?.parts) ? info.parts : undefined const parts = eventParts ?? infoParts ?? [] - const hasAnyPart = parts.some((part) => typeof part?.type === "string") + const hasAnyPart = parts.some((part) => isRecord(part) && typeof part.type === "string") if (hasError || hasFinish || hasAnyPart) { watchdog.onAssistantProgress(sessionID) } @@ -86,9 +89,7 @@ export function observeEventForWatchdog( } if (TERMINAL_EVENT_TYPES.has(event.type)) { - const sessionID = - (props.sessionID as string | undefined) ?? - ((props.info as Record | undefined)?.id as string | undefined) + const sessionID = resolveSessionEventID(props) if (sessionID) watchdog.onSessionTerminal(sessionID) } } 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 diff --git a/src/shared/prompt-async-gate.ts b/src/shared/prompt-async-gate.ts index 35dfca218..a54f98713 100644 --- a/src/shared/prompt-async-gate.ts +++ b/src/shared/prompt-async-gate.ts @@ -341,15 +341,36 @@ function messageRole(message: unknown): string | undefined { return typeof message.role === "string" ? message.role : undefined } -function messageFinish(message: unknown): string | undefined { +function messageFinish(message: unknown): string | true | undefined { if (!isRecord(message)) { return undefined } const info = message.info - if (isRecord(info) && typeof info.finish === "string") { - return info.finish + if (isRecord(info)) { + if (info.finish === true) { + return true + } + if (typeof info.finish === "string" && info.finish.length > 0) { + return info.finish + } } - return typeof message.finish === "string" ? message.finish : undefined + if (message.finish === true) { + return true + } + return typeof message.finish === "string" && message.finish.length > 0 ? message.finish : undefined +} + +function messageCompleted(message: unknown): boolean { + if (!isRecord(message)) { + return false + } + const info = message.info + const time = isRecord(info) && isRecord(info.time) ? info.time : undefined + const completed = time?.completed + if (typeof completed === "number" && Number.isFinite(completed)) { + return true + } + return typeof completed === "string" && completed.length > 0 } function toInternalInitiatorTextPartLike(part: unknown): InternalInitiatorTextPartLike { @@ -419,7 +440,13 @@ function latestAssistantTurnBlocksInternalPrompt(messages: unknown[]): boolean { const message = messages[index] const role = messageRole(message) if (role === "assistant") { + if (messageCompleted(message)) { + return false + } const finish = messageFinish(message) + if (finish === true) { + return false + } if (finish === undefined || finish === "unknown") { return true }