From 44ef5dec1da45de67d5cace0fcf239c32b642a31 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 19 May 2026 13:48:45 +0900 Subject: [PATCH] fix(runtime-events): honor OpenCode progress shapes --- src/hooks/context-window-monitor.ts | 5 +- src/hooks/preemptive-compaction.ts | 5 +- .../first-prompt-watchdog.test.ts | 12 ++++ .../runtime-fallback/first-prompt-watchdog.ts | 12 ++++ src/plugin/event.model-fallback.test.ts | 56 +++++++++++++++++++ src/plugin/event.ts | 10 +++- 6 files changed, 93 insertions(+), 7 deletions(-) diff --git a/src/hooks/context-window-monitor.ts b/src/hooks/context-window-monitor.ts index 30e241c3f..507ea10b7 100644 --- a/src/hooks/context-window-monitor.ts +++ b/src/hooks/context-window-monitor.ts @@ -101,11 +101,12 @@ export function createContextWindowMonitorHook( sessionID?: string providerID?: string modelID?: string - finish?: boolean + finish?: unknown tokens?: TokenInfo } | undefined - if (!info || info.role !== "assistant" || !info.finish) return + const finish = info?.finish + if (!info || info.role !== "assistant" || !finish) return if (isCompactionAgent(info.agent)) return const sessionID = resolveMessageEventSessionID(props) if (!sessionID || !info.providerID || !info.tokens) return diff --git a/src/hooks/preemptive-compaction.ts b/src/hooks/preemptive-compaction.ts index 28da76192..88792ffe5 100644 --- a/src/hooks/preemptive-compaction.ts +++ b/src/hooks/preemptive-compaction.ts @@ -76,13 +76,14 @@ export function createPreemptiveCompactionHook( sessionID?: string providerID?: string modelID?: string - finish?: boolean + finish?: unknown tokens?: TokenInfo parts?: unknown } | undefined const sessionID = resolveMessageEventSessionID(props) - if (!info || info.role !== "assistant" || !info.finish || !sessionID) return + const finish = info?.finish + if (!info || info.role !== "assistant" || !finish || !sessionID) return if (isCompactionAgent(info.agent)) return if (info.providerID && info.tokens) { diff --git a/src/hooks/runtime-fallback/first-prompt-watchdog.test.ts b/src/hooks/runtime-fallback/first-prompt-watchdog.test.ts index 298475c5e..98767bf65 100644 --- a/src/hooks/runtime-fallback/first-prompt-watchdog.test.ts +++ b/src/hooks/runtime-fallback/first-prompt-watchdog.test.ts @@ -277,6 +277,18 @@ describe("observeEventForWatchdog", () => { expect(calls.progress).toEqual([sessionID]) }) + it.each(assistantProgressParts)("#given a message.part.updated event whose part is type=%s #when observed #then onAssistantProgress is called", (_label: string, part: { readonly type: string; readonly text?: string; readonly id?: string; readonly name?: string; readonly tool_use_id?: string }) => { + const calls = freshCalls() + observeEventForWatchdog( + { + type: "message.part.updated", + properties: { sessionID, part }, + }, + createRecordingWatchdog(calls), + ) + expect(calls.progress).toEqual([sessionID]) + }) + it("#given a message.updated assistant event with parts: [] and no error/finish #when observed #then no progress is signalled (no activity yet)", () => { const calls = freshCalls() observeEventForWatchdog( diff --git a/src/hooks/runtime-fallback/first-prompt-watchdog.ts b/src/hooks/runtime-fallback/first-prompt-watchdog.ts index fdcbda8b3..1af473d40 100644 --- a/src/hooks/runtime-fallback/first-prompt-watchdog.ts +++ b/src/hooks/runtime-fallback/first-prompt-watchdog.ts @@ -46,6 +46,18 @@ export function observeEventForWatchdog( const props = event.properties as Record | 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) { + watchdog.onAssistantProgress(sessionID) + } + return + } + if (event.type === "message.updated") { const info = props.info as Record | undefined const sessionID = info?.sessionID as string | undefined diff --git a/src/plugin/event.model-fallback.test.ts b/src/plugin/event.model-fallback.test.ts index a711ef08b..84b0219d5 100644 --- a/src/plugin/event.model-fallback.test.ts +++ b/src/plugin/event.model-fallback.test.ts @@ -31,6 +31,7 @@ describe("createEventHandler - model fallback", () => { const createHandler = (args?: { hooks?: any pluginConfig?: any + abort?: (input: { path: { id: string } }) => Promise promptAsync?: (input: { path: { id: string } }) => Promise }) => { setupConnectedProviderCacheMocks() @@ -41,6 +42,9 @@ describe("createEventHandler - model fallback", () => { const sessionClient = { abort: async ({ path }: { path: { id: string } }) => { abortCalls.push(path.id) + if (args?.abort) { + return args.abort({ path }) + } return {} }, prompt: async ({ path }: { path: { id: string } }) => { @@ -305,6 +309,58 @@ describe("createEventHandler - model fallback", () => { expect(abortCalls).toEqual([sessionID]) }) + test("#given abort fails before model-fallback continuation #when fallback handles assistant error #then it does not inject another prompt", async () => { + //#given + const sessionID = "ses_model_fallback_abort_failure" + setMainSession(sessionID) + let pendingFallbackArms = 0 + const modelFallback = unsafeTestValue({ + setSessionFallbackChain: () => {}, + setPendingModelFallback: () => { + pendingFallbackArms += 1 + return true + }, + }) + const { handler, abortCalls, promptAsyncCalls } = createHandler({ + hooks: { modelFallback }, + abort: async () => { + throw new Error("abort transport failed") + }, + promptAsync: async () => ({}), + }) + const assistantError = { + name: "APIError", + data: { + message: + "Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-7-thinking\"}}", + isRetryable: true, + }, + } + + //#when + await handler({ + event: { + type: "message.updated", + properties: { + info: { + id: "msg_err_abort_failure", + sessionID, + role: "assistant", + error: assistantError, + modelID: "claude-opus-4-7-thinking", + providerID: "anthropic", + agent: "Sisyphus - Ultraworker", + }, + }, + }, + }) + + //#then + expect(pendingFallbackArms).toBe(1) + expect(abortCalls).toEqual([sessionID]) + expect(promptAsyncCalls).toEqual([]) + }) + test("does not collapse fallback continuations for different providers with the same model id", async () => { //#given const sessionID = "ses_model_fallback_same_model_different_provider" diff --git a/src/plugin/event.ts b/src/plugin/event.ts index ed2f6f901..3c4db5340 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -474,9 +474,12 @@ export function createEventHandler(args: { modelFallbackContinuationsInFlight.add(sessionID); let dispatched = false; try { - await pluginContext.client.session.abort({ path: { id: sessionID } }).catch((error) => { + try { + await pluginContext.client.session.abort({ path: { id: sessionID } }); + } catch (error) { log("[event] model-fallback abort failed", { sessionID, source, error }); - }); + return; + } releasePromptAsyncReservation(sessionID, `model-fallback-abort:${source}`, { reservedBy: [`model-fallback:${source}`, `model-fallback:${source}:sync`], reservedByPrefix: "model-fallback:", @@ -755,7 +758,8 @@ export function createEventHandler(args: { const sessionID = resolveMessageEventSessionID(props); const agent = info?.agent as string | undefined; const role = info?.role as string | undefined; - if (sessionID && info?.finish === true) { + const finish = info?.finish; + if (sessionID && ((typeof finish === "string" && finish.length > 0) || finish === true)) { invalidateContextWindowUsageCache(pluginContext as PluginInput, sessionID); } if (sessionID && role === "user") {