From fbcc1435e2eb3c1041a389d66ade9115d4f4baee Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 20 May 2026 18:19:26 +0900 Subject: [PATCH] fix(runtime-fallback): recognize completion progress events --- ...st-prompt-watchdog-progress-events.test.ts | 90 +++++++++++++++++++ .../runtime-fallback/first-prompt-watchdog.ts | 23 ++++- 2 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 src/hooks/runtime-fallback/first-prompt-watchdog-progress-events.test.ts diff --git a/src/hooks/runtime-fallback/first-prompt-watchdog-progress-events.test.ts b/src/hooks/runtime-fallback/first-prompt-watchdog-progress-events.test.ts new file mode 100644 index 000000000..732497137 --- /dev/null +++ b/src/hooks/runtime-fallback/first-prompt-watchdog-progress-events.test.ts @@ -0,0 +1,90 @@ +/// + +import { describe, expect, it } from "bun:test" + +import { observeEventForWatchdog, type FirstPromptWatchdog } from "./first-prompt-watchdog" + +interface RecordedWatchdogCalls { + readonly user: string[] + readonly progress: string[] + readonly terminal: string[] +} + +function createRecordingWatchdog(calls: RecordedWatchdogCalls): FirstPromptWatchdog { + return { + onUserMessage(sessionID) { + calls.user.push(sessionID) + }, + onAssistantProgress(sessionID) { + calls.progress.push(sessionID) + }, + onSessionTerminal(sessionID) { + calls.terminal.push(sessionID) + }, + dispose() {}, + } +} + +function freshCalls(): RecordedWatchdogCalls { + return { user: [], progress: [], terminal: [] } +} + +describe("observeEventForWatchdog progress markers", () => { + const sessionID = "session-observed-progress" + + for (const [label, marker] of [ + ["finished boolean", { finished: true }], + ["completed boolean", { completed: true }], + ["completed time", { time: { completed: 1779267000000 } }], + ] as const) { + it(`#given a message.updated assistant event with ${label} #when observed #then onAssistantProgress is called`, () => { + const calls = freshCalls() + observeEventForWatchdog( + { + type: "message.updated", + properties: { info: { sessionID, role: "assistant", ...marker } }, + }, + createRecordingWatchdog(calls), + ) + expect(calls.progress).toEqual([sessionID]) + }) + } + + it("#given a message.updated assistant event with false completion markers and no parts #when observed #then no progress is signalled", () => { + const calls = freshCalls() + observeEventForWatchdog( + { + type: "message.updated", + properties: { + info: { + sessionID, + role: "assistant", + completed: false, + finish: false, + finished: false, + }, + parts: [], + }, + }, + createRecordingWatchdog(calls), + ) + expect(calls.progress).toEqual([]) + }) + + for (const [eventType] of [ + ["session.next.text.delta"], + ["session.next.reasoning.delta"], + ["session.next.tool.called"], + ["session.next.tool.success"], + ["session.next.step.ended"], + ] as const) { + it(`#given a ${eventType} event with a sessionID #when observed #then onAssistantProgress is called`, () => { + const calls = freshCalls() + observeEventForWatchdog( + { type: eventType, properties: { sessionID } }, + createRecordingWatchdog(calls), + ) + expect(calls.progress).toEqual([sessionID]) + }) + } +}) diff --git a/src/hooks/runtime-fallback/first-prompt-watchdog.ts b/src/hooks/runtime-fallback/first-prompt-watchdog.ts index 7ee8f2265..72ff2230d 100644 --- a/src/hooks/runtime-fallback/first-prompt-watchdog.ts +++ b/src/hooks/runtime-fallback/first-prompt-watchdog.ts @@ -11,6 +11,7 @@ import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model" import { dispatchFallbackRetry } from "./fallback-retry-dispatcher" const SOURCE = "first-prompt-watchdog" +const SESSION_NEXT_EVENT_PREFIX = "session.next." declare function setTimeout(callback: () => void | Promise, delay?: number): RuntimeFallbackTimeout declare function clearTimeout(timeout: RuntimeFallbackTimeout): void @@ -29,6 +30,19 @@ const TERMINAL_EVENT_TYPES = new Set([ "session.error", ]) +function isCompletionMarker(value: unknown): boolean { + if (typeof value === "boolean") return value + return value !== undefined && value !== null +} + +function hasAssistantCompletionMarker(info: Record): boolean { + const time = isRecord(info.time) ? info.time : undefined + return isCompletionMarker(info.finish) + || isCompletionMarker(info.finished) + || isCompletionMarker(info.completed) + || isCompletionMarker(time?.completed) +} + /** * Translate an OpenCode session event into the appropriate watchdog signal. * @@ -48,6 +62,12 @@ export function observeEventForWatchdog( const props = isRecord(event.properties) ? event.properties : undefined if (!props) return + if (event.type.startsWith(SESSION_NEXT_EVENT_PREFIX)) { + const sessionID = resolveSessionEventID(props) ?? resolveMessageEventSessionID(props) + if (sessionID) watchdog.onAssistantProgress(sessionID) + return + } + if (event.type === "message.part.updated" || event.type === "message.part.delta") { const sessionID = resolveMessageEventSessionID(props) const part = isRecord(props.part) ? props.part : undefined @@ -63,6 +83,7 @@ export function observeEventForWatchdog( if (event.type === "message.updated") { const info = isRecord(props.info) ? props.info : undefined + if (!info) return const sessionID = typeof info?.sessionID === "string" ? info.sessionID : undefined const role = typeof info?.role === "string" ? info.role : undefined if (!sessionID || !role) return @@ -76,7 +97,7 @@ export function observeEventForWatchdog( if (role === "assistant") { const hasError = info?.error !== undefined - const hasFinish = info?.finish !== undefined + const hasFinish = hasAssistantCompletionMarker(info) const eventParts = Array.isArray(props.parts) ? props.parts : undefined const infoParts = Array.isArray(info?.parts) ? info.parts : undefined const parts = eventParts ?? infoParts ?? []