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.
This commit is contained in:
Kelvin Amoaba
2026-11-04 21:24:54 +00:00
committed by GitHub
parent dd4e5001b1
commit e7801630fa
2 changed files with 41 additions and 3 deletions
@@ -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 }
])
})
})
@@ -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 }
}
}