From e7801630fa46feed463bcd26a9078d027bb8d572 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:24:54 +0000 Subject: [PATCH] fix(program-status): announce a finished OpenCode turn reported only over OSC 7501 (#38222) OpenCode ends every turn with idle and never reports done, so its idle after work became a session boundary and no finished notification fired. --- .../agent-status-projector.test.ts | 31 +++++++++++++++++++ .../program-status/agent-status-projector.ts | 13 ++++++-- 2 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 src/shared/program-status/agent-status-projector.test.ts diff --git a/src/shared/program-status/agent-status-projector.test.ts b/src/shared/program-status/agent-status-projector.test.ts new file mode 100644 index 00000000000..ff9f9185b6c --- /dev/null +++ b/src/shared/program-status/agent-status-projector.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { createProgramStatusProjector } from './agent-status-projector' + +const osc = (body: string): string => `\x1b]7501;${body}\x1b\\` + +function lastRootStates(data: string[]) { + const projector = createProgramStatusProjector(() => 1000) + return data.map((chunk) => { + const payloads = projector.process(chunk).payloads + const last = payloads.at(-1)?.payload + return last && { state: last.state, sessionBoundary: last.sessionBoundary } + }) +} + +describe('createProgramStatusProjector', () => { + it('ends an OpenCode turn on idle but not its startup', () => { + // Captured from OpenCode 2.2.5, which never reports done. + const session = 'ses_e57cc6093ffeU9dbfhOFmwKNgt' + expect( + lastRootStates([ + osc('state=idle:app=opencode'), + osc('state=working:app=opencode') + osc(`state=working:id=${session}:app=opencode`), + osc(`state=clear:id=${session}`) + osc('state=idle:app=opencode') + ]) + ).toEqual([ + { state: 'done', sessionBoundary: true }, + { state: 'working', sessionBoundary: undefined }, + { state: 'done', sessionBoundary: undefined } + ]) + }) +}) diff --git a/src/shared/program-status/agent-status-projector.ts b/src/shared/program-status/agent-status-projector.ts index 6eab0c6e54b..d7f271fbc91 100644 --- a/src/shared/program-status/agent-status-projector.ts +++ b/src/shared/program-status/agent-status-projector.ts @@ -154,10 +154,17 @@ export function createProgramStatusProjector(now: () => number = Date.now): Prog case 'error': return { state: 'done', outcome: 'failure' } case 'idle': - // Idle may follow startup work (Codex), so it proves rest, not cancellation; interrupt keystrokes own that verdict. + if (newInvocation || lastRoot === null || lastRoot.sessionBoundary === true) { + return { state: 'done', sessionBoundary: true } + } // Keep unseen done results when the same invocation rests. - return !newInvocation && lastRoot?.state === 'done' && lastRoot.sessionBoundary !== true - ? lastRoot + if (lastRoot.state === 'done') { + return lastRoot + } + // OpenCode ends every turn with idle, never done, so its idle after work is a turn end with no verdict. + // Others' idle may follow startup work (Codex), so it proves rest; interrupt keystrokes own cancellation. + return root.app === 'opencode' + ? { state: 'done' } : { state: 'done', sessionBoundary: true } } }