mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 18:17:36 +00:00
* fix(program-status): keep a Pi row working while a pi-subagents child outlives the parent's done OSC 7501 projected the root record's done straight onto the row even when live child records remained, so a Pi parent showed Done/IDLE while an async pi-subagents worker kept running. Fold child liveness with the existing foldMainAgentWithRowChildWork; mainAgent keeps the lead's own verdict so the row settles to done once the last child ends. Fixes #33657 * fix(program-status): announce a held turn's done on time and keep hook verdicts authoritative - Stamp turnCompletedAt on the child-held row and its all-clear so the lead's done is announced once, when it happens, as hook rows do. - Hold only a finished (non-boundary) turn, only for working/waiting children; in-turn rows and failed/idle children keep the root's state. - A settled hook row is never reopened by a program's child-held working row, so the 2-minute silence fallback cannot flip done -> working -> done. * fix(program-status): lapse an unrefreshed child hold to the lead's done Children report only changes, so a pi-subagents that dies or hangs inside a live Pi would hold the row working until decay. The lease timer now settles a child-held row no report refreshed within AGENT_STATUS_STALE_AFTER_MS minus one renewal interval (20-30 min) to the lead's done, keeping turnCompletedAt so it is not announced again.
319 lines
13 KiB
TypeScript
319 lines
13 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
||
import {
|
||
createProgramStatusProjector,
|
||
lapsedProgramStatusChildHold
|
||
} from './agent-status-projector'
|
||
|
||
const b64 = (text: string): string => Buffer.from(text, 'utf8').toString('base64')
|
||
const osc = (body: string): string => `\x1b]7501;${body}\x1b\\`
|
||
|
||
function projector() {
|
||
let clock = 1000
|
||
const instance = createProgramStatusProjector(() => clock)
|
||
return {
|
||
feed: (data: string) => instance.process(data).payloads.map(({ payload }) => payload),
|
||
tick: (ms: number) => {
|
||
clock += ms
|
||
}
|
||
}
|
||
}
|
||
|
||
describe('createProgramStatusProjector', () => {
|
||
it('maps a Claude Code turn onto Orca agent states', () => {
|
||
const { feed, tick } = projector()
|
||
expect(feed(osc('state=idle:app=claude-code'))).toEqual([
|
||
{
|
||
state: 'done',
|
||
prompt: '',
|
||
agentType: 'claude',
|
||
sessionBoundary: true,
|
||
mainAgent: { state: 'done', stateStartedAt: 1000 }
|
||
}
|
||
])
|
||
tick(5)
|
||
expect(feed(osc(`state=working:app=claude-code:msg=${b64('Fixing the parser')}`))).toEqual([
|
||
{
|
||
state: 'working',
|
||
prompt: '',
|
||
agentType: 'claude',
|
||
toolInput: 'Fixing the parser',
|
||
mainAgent: { state: 'working', stateStartedAt: 1005 }
|
||
}
|
||
])
|
||
tick(5)
|
||
expect(
|
||
feed(
|
||
osc(
|
||
`state=blocked:kind=permission:app=claude-code:msg=${b64('approve Bash: rm -rf build')}`
|
||
)
|
||
)
|
||
).toMatchObject([{ state: 'waiting', toolInput: 'approve Bash: rm -rf build' }])
|
||
tick(5)
|
||
expect(feed(osc(`state=done:app=claude-code:msg=${b64('Parser fixed')}`))).toEqual([
|
||
{
|
||
state: 'done',
|
||
prompt: '',
|
||
agentType: 'claude',
|
||
lastAssistantMessage: 'Parser fixed',
|
||
mainAgent: { state: 'done', outcome: 'success', stateStartedAt: 1015 }
|
||
}
|
||
])
|
||
})
|
||
|
||
it('reports auth and unnamed blocks as blocked, errors as failed turns', () => {
|
||
const { feed } = projector()
|
||
expect(feed(osc('state=blocked:kind=auth:app=pi'))).toMatchObject([
|
||
{ state: 'blocked', agentType: 'pi' }
|
||
])
|
||
expect(feed(osc(`state=error:app=pi:msg=${b64('rate limited')}`))).toMatchObject([
|
||
{ state: 'done', lastAssistantMessage: 'rate limited', mainAgent: { outcome: 'failure' } }
|
||
])
|
||
})
|
||
|
||
it('reads idle as at rest, never as a cancelled turn, and keeps an unseen done', () => {
|
||
const { feed } = projector()
|
||
// Codex boots with a short working blip before settling idle.
|
||
feed(osc('state=idle:app=codex'))
|
||
feed(osc('state=working:app=codex'))
|
||
const [settled] = feed(osc('state=idle:app=codex'))
|
||
expect(settled).toMatchObject({ state: 'done', sessionBoundary: true })
|
||
expect(settled.interrupted).toBeUndefined()
|
||
expect(settled.mainAgent?.outcome).toBeUndefined()
|
||
feed(osc('state=working:app=codex'))
|
||
feed(osc('state=done:app=codex'))
|
||
expect(feed(osc('state=idle:app=codex'))).toEqual([])
|
||
})
|
||
|
||
it('emits only changes, so redraws that resend a report are silent', () => {
|
||
const { feed } = projector()
|
||
expect(feed(osc('state=working:app=omp'))).toHaveLength(1)
|
||
expect(feed(osc('state=working:app=omp') + osc('state=working:app=omp'))).toEqual([])
|
||
})
|
||
|
||
it('ends a live program at the next shell prompt but keeps a finished one', () => {
|
||
const { feed } = projector()
|
||
feed(osc('state=working:app=pi'))
|
||
expect(feed('\x1b]133;A\x07')).toMatchObject([{ state: 'done', sessionBoundary: true }])
|
||
feed(osc('state=done:app=pi'))
|
||
expect(feed('\x1b]133;A\x07')).toEqual([])
|
||
})
|
||
|
||
it('turns child records into subagent rows with inherited app and sanitized titles', () => {
|
||
const { feed, tick } = projector()
|
||
feed(osc('state=working:app=claude-code'))
|
||
tick(10)
|
||
expect(feed(osc(`state=working:id=task_1:title=${b64('Explore the repo')}`))).toMatchObject([
|
||
{
|
||
state: 'working',
|
||
subagents: [
|
||
{
|
||
id: 'task_1',
|
||
state: 'working',
|
||
agentType: 'claude',
|
||
description: 'Explore the repo',
|
||
startedAt: 1010
|
||
}
|
||
]
|
||
}
|
||
])
|
||
expect(feed(osc('state=blocked:kind=question:id=task_1'))).toMatchObject([
|
||
{ subagents: [{ id: 'task_1', state: 'waiting', startedAt: 1010 }] }
|
||
])
|
||
const [cleared] = feed(osc('state=clear:id=task_1'))
|
||
expect(cleared.subagents).toBeUndefined()
|
||
})
|
||
|
||
it("keeps a finished turn working while a child runs, stamping the turn's own end for both rows", () => {
|
||
const { feed, tick } = projector()
|
||
feed(osc('state=working:app=pi') + osc('state=working:id=subagents/run1:app=pi-subagents'))
|
||
tick(10)
|
||
expect(feed(osc('state=done:app=pi'))).toMatchObject([
|
||
{
|
||
state: 'working',
|
||
turnCompletedAt: 1010,
|
||
mainAgent: { state: 'done', outcome: 'success', stateStartedAt: 1010 },
|
||
subagents: [{ id: 'subagents/run1', state: 'working' }]
|
||
}
|
||
])
|
||
tick(10)
|
||
const [settled] = feed(osc('state=done:id=subagents/run1:app=pi-subagents'))
|
||
expect(settled).toMatchObject({
|
||
state: 'done',
|
||
turnCompletedAt: 1010,
|
||
mainAgent: { state: 'done', outcome: 'success', stateStartedAt: 1010 }
|
||
})
|
||
expect(settled.subagents).toBeUndefined()
|
||
})
|
||
|
||
it("lapses a hold no child refreshed to the lead's done, keeping its completion stamp", () => {
|
||
const { feed } = projector()
|
||
feed(osc('state=working:app=pi') + osc('state=working:id=subagents/run1'))
|
||
const [held] = feed(osc('state=done:app=pi'))
|
||
expect(lapsedProgramStatusChildHold(held, 0, 999, 1000)).toBeNull()
|
||
const lapsed = lapsedProgramStatusChildHold(held, 0, 1000, 1000)
|
||
expect(lapsed).toMatchObject({
|
||
state: 'done',
|
||
turnCompletedAt: held.turnCompletedAt,
|
||
mainAgent: held.mainAgent
|
||
})
|
||
expect(lapsed?.subagents).toBeUndefined()
|
||
const [settled] = feed(osc('state=done:id=subagents/run1'))
|
||
expect(lapsedProgramStatusChildHold(settled, 0, 1000, 1000)).toBeNull()
|
||
})
|
||
|
||
it('leaves live turns, failed children and an idle root as their root reports them', () => {
|
||
const { feed } = projector()
|
||
const last = (data: string) => feed(data).at(-1)
|
||
expect(
|
||
last(osc('state=working:app=claude-code') + osc('state=blocked:kind=question:id=task'))
|
||
).toMatchObject({ state: 'working', subagents: [{ state: 'waiting' }] })
|
||
const failedChild = last(osc('state=blocked:id=task') + osc('state=done:app=claude-code'))
|
||
expect(failedChild).toMatchObject({ state: 'done', subagents: [{ state: 'blocked' }] })
|
||
expect(failedChild?.turnCompletedAt).toBeUndefined()
|
||
const resumed = projector()
|
||
expect(
|
||
resumed.feed(osc('state=idle:app=pi') + osc('state=working:id=subagents/run1')).at(-1)
|
||
).toMatchObject({ state: 'done', sessionBoundary: true })
|
||
})
|
||
|
||
it('reads a clear during live work as the program leaving, not a cancelled turn', () => {
|
||
const { feed } = projector()
|
||
feed(osc('state=working:app=claude-code'))
|
||
const [left] = feed(osc('state=clear'))
|
||
expect(left).toMatchObject({ state: 'done', sessionBoundary: true })
|
||
expect(left.interrupted).toBeUndefined()
|
||
expect(feed(osc('state=clear'))).toEqual([])
|
||
})
|
||
|
||
it("maps harness app ids onto Orca's agent ids so hook precedence can match them", () => {
|
||
const { feed } = projector()
|
||
expect(feed(osc('state=working:app=grok-build'))).toMatchObject([{ agentType: 'grok' }])
|
||
expect(feed(osc('state=done:app=terraform'))).toMatchObject([{ agentType: 'terraform' }])
|
||
})
|
||
|
||
it('emits the net facet per chunk, including progress the lifecycle payload ignores', () => {
|
||
let clock = 1
|
||
const { process } = createProgramStatusProjector(() => clock)
|
||
expect(process(osc('state=working:app=claude-code:progress=40')).facet).toEqual({
|
||
state: 'working',
|
||
progress: 40,
|
||
observedAt: 1
|
||
})
|
||
clock = 2
|
||
const tick = process(osc('state=working:app=claude-code:progress=55'))
|
||
expect(tick.payloads).toEqual([])
|
||
expect(tick.facet).toMatchObject({ progress: 55, observedAt: 2 })
|
||
expect(process(osc('state=working:app=claude-code:progress=55')).facet).toBeUndefined()
|
||
expect(process(osc('state=clear')).facet).toBeNull()
|
||
})
|
||
|
||
it('stays silent on children alone, then lists them once the root reports', () => {
|
||
const { feed } = projector()
|
||
expect(feed(osc('state=working:id=job'))).toEqual([])
|
||
expect(feed(osc('state=working:app=pi'))).toMatchObject([
|
||
{ state: 'working', subagents: [{ id: 'job', state: 'working', agentType: 'pi' }] }
|
||
])
|
||
})
|
||
|
||
it("keeps a failed turn's verdict when the program settles idle", () => {
|
||
const { feed } = projector()
|
||
feed(osc('state=working:app=pi'))
|
||
expect(feed(osc('state=error:app=pi'))).toMatchObject([{ mainAgent: { outcome: 'failure' } }])
|
||
expect(feed(osc('state=idle:app=pi'))).toEqual([])
|
||
})
|
||
|
||
it("reports a child's app change as news", () => {
|
||
const { feed } = projector()
|
||
feed(osc('state=working:app=pi') + osc('state=working:id=job'))
|
||
expect(feed(osc('state=working:id=job:app=codex'))).toMatchObject([
|
||
{ subagents: [{ id: 'job', agentType: 'codex' }] }
|
||
])
|
||
})
|
||
|
||
it('drops a finished row when the program clears it or the terminal resets', () => {
|
||
const { feed } = projector()
|
||
feed(osc('state=done:app=brew'))
|
||
expect(feed(osc('state=clear'))).toMatchObject([{ state: 'done', sessionBoundary: true }])
|
||
feed(osc('state=error:app=brew'))
|
||
expect(feed('\x1bc')).toMatchObject([{ state: 'done', sessionBoundary: true }])
|
||
})
|
||
|
||
it('reports an identical result from the next invocation as news', () => {
|
||
const { feed, tick } = projector()
|
||
expect(feed(osc('state=done:app=brew'))).toHaveLength(1)
|
||
feed('\x1b]133;A\x07')
|
||
tick(10)
|
||
expect(feed(osc('state=done:app=brew'))).toMatchObject([
|
||
{ state: 'done', mainAgent: { stateStartedAt: 1010 } }
|
||
])
|
||
})
|
||
|
||
it("starts the next invocation's idle as a boundary, not the kept result", () => {
|
||
const { feed } = projector()
|
||
feed(osc('state=done:app=brew'))
|
||
feed('\x1b]133;A\x07')
|
||
expect(feed(osc('state=idle:app=codex'))).toMatchObject([
|
||
{ state: 'done', sessionBoundary: true, agentType: 'codex' }
|
||
])
|
||
})
|
||
|
||
it('ends the live root at the prompt while keeping the finished record for display', () => {
|
||
const instance = createProgramStatusProjector(() => 0)
|
||
instance.process(osc('state=done:app=terraform'))
|
||
const atPrompt = instance.process('\x1b]133;A\x07')
|
||
expect(atPrompt.rootChanges).toEqual([{ offset: 8, root: undefined }])
|
||
expect(atPrompt.facet).toBeUndefined()
|
||
expect(instance.process('\x1b]133;A\x07').rootChanges).toEqual([])
|
||
})
|
||
|
||
it('never resolves an app name to an Object.prototype member', () => {
|
||
const { feed } = projector()
|
||
expect(feed(osc('state=working:app=constructor'))).toMatchObject([{ agentType: 'constructor' }])
|
||
expect(feed(osc('state=working:app=toString'))).toMatchObject([{ agentType: 'toString' }])
|
||
})
|
||
|
||
it('keeps long child ids distinct within the snapshot cap, leaf last', () => {
|
||
const { feed } = projector()
|
||
const path = (head: string) => [head, 'b'.repeat(30), 'c'.repeat(30)].join('/')
|
||
feed(osc('state=working:app=pi'))
|
||
const [payload] = feed(
|
||
`${osc(`state=working:id=${path('a')}`)}${osc(`state=working:id=${path('z')}`)}`
|
||
).slice(-1)
|
||
const ids = payload.subagents?.map((child) => child.id) ?? []
|
||
expect(new Set(ids).size).toBe(2)
|
||
expect(ids.every((id) => id.length <= 64 && id.endsWith('c'.repeat(30)))).toBe(true)
|
||
})
|
||
|
||
it('marks a projection whose program left no records as removing its row', () => {
|
||
const instance = createProgramStatusProjector(() => 0)
|
||
instance.process(osc('state=done:app=brew'))
|
||
expect(instance.process(osc('state=clear')).payloads).toMatchObject([{ removesRow: true }])
|
||
instance.process(osc('state=idle:app=codex'))
|
||
expect(instance.process(osc('state=working:app=codex')).payloads[0]?.removesRow).toBeUndefined()
|
||
})
|
||
|
||
it('keeps a result unchanged when the prompt drops a lingering child', () => {
|
||
const { feed, tick } = projector()
|
||
feed(osc('state=working:app=pi') + osc('state=working:id=child'))
|
||
feed(osc('state=done:app=pi'))
|
||
tick(5)
|
||
// The child drop at the prompt is not a new completion of the kept result.
|
||
expect(feed('\x1b]133;A\x07')).toMatchObject([
|
||
{ state: 'done', mainAgent: { stateStartedAt: 1000 } }
|
||
])
|
||
tick(5)
|
||
expect(feed(osc('state=done:app=pi'))).toMatchObject([
|
||
{ state: 'done', mainAgent: { stateStartedAt: 1010 } }
|
||
])
|
||
})
|
||
|
||
it('removes the row when an idle program clears or the terminal resets', () => {
|
||
for (const ending of [osc('state=clear'), '\x1bc']) {
|
||
const instance = createProgramStatusProjector(() => 0)
|
||
instance.process(osc('state=idle:app=brew'))
|
||
expect(instance.process(ending).payloads).toMatchObject([{ removesRow: true }])
|
||
expect(instance.process(ending).payloads).toEqual([])
|
||
}
|
||
})
|
||
})
|