diff --git a/src/main/codex/codex-structured-child-work-producer.test.ts b/src/main/codex/codex-structured-child-work-producer.test.ts index b39119dc184..175b6cfb734 100644 --- a/src/main/codex/codex-structured-child-work-producer.test.ts +++ b/src/main/codex/codex-structured-child-work-producer.test.ts @@ -1,6 +1,7 @@ // A Codex session's frames, through the real adapter, into the host's child records: the order the -// host receives them in, and whether the parent row the records imply is today's row. +// host receives them in, and the parent row they fold to, written out step by step. +import { isDeepStrictEqual } from 'node:util' import { describe, expect, it } from 'vitest' import { foldAgentLeadStatus, @@ -42,8 +43,8 @@ const LINTER = 'thread-linter' type Frame = { method: string; params: Record } type Delivery = { kind: 'journal' | 'publish' | 'evidence'; detail: string } type Liveness = ReturnType -/** What the records and today's strip each said when the journal wrote or published a row. */ -type JournalMoment = { recorded: Liveness; legacy: Liveness } +/** What the records said when the journal wrote or published a row. */ +type JournalMoment = { recorded: Liveness; commandLive: boolean } const turn = ( method: 'turn/started' | 'turn/completed', @@ -99,7 +100,9 @@ async function producer() { const moment = () => moments.push({ recorded: recordedLiveness(), - legacy: agentChildWorkLiveness(adapter.backgroundTaskState('session-1')?.tasks) + commandLive: records().some( + (record) => record.kind === 'command' && record.membership === 'live' + ) }) const adapter = new CodexStructuredSessionAdapter({ resolveLaunch: async () => ({ @@ -180,8 +183,6 @@ const fold = ( leadState: 'working' | 'done', childWorkLiveness: Liveness ): AgentLeadStatusResolution => foldAgentLeadStatus({ leadState, childWorkLiveness }) -/** The strip has no word for a child waiting on a human: to it, that child is working. */ -const asStrip = (liveness: Liveness): Liveness => (liveness === 'waiting' ? 'working' : liveness) const shellFrame = ( method: 'item/started' | 'item/completed', threadId: string, @@ -216,28 +217,43 @@ describe('Codex structured child-work producer', () => { ]) }) - it('records the parent state today reads, at every journal write, while adding outcome and activity', async () => { + it("folds the parent from the records, at every journal write, with each child's outcome and activity", async () => { const { adapter, momentsOf, records, recordedLiveness, byDescription, display } = await producer() const steps: { frame: Frame lead: 'working' | 'done' - childWaits?: true + live: Liveness + parent: AgentLeadStatusResolution check?: () => void }[] = [ - { frame: turn('turn/started', THREAD_ID, 'p1'), lead: 'working' }, + { + frame: turn('turn/started', THREAD_ID, 'p1'), + lead: 'working', + live: null, + parent: { stateName: 'working' } + }, // Codex reports the child's turn before its announcement. { frame: turn('turn/started', REVIEWER, 'r1'), lead: 'working', + live: null, + parent: { stateName: 'working' }, check: () => expect(records()).toEqual([]) }, - { frame: spawned(REVIEWER, 'review', 'p1'), lead: 'working' }, + { + frame: spawned(REVIEWER, 'review', 'p1'), + lead: 'working', + live: 'working', + parent: { stateName: 'working' } + }, // An approved command: Codex starts it on the approval path and reports its exit from // unified exec. { frame: shellFrame('item/started', REVIEWER, 'r1', 'cmd-1', 'npm test', 'agent'), lead: 'working', + live: 'working', + parent: { stateName: 'working' }, check: () => { expect(byDescription('review')?.operation).toMatchObject({ toolName: 'Bash', @@ -254,6 +270,8 @@ describe('Codex structured child-work producer', () => { { frame: shellFrame('item/started', REVIEWER, 'r1', 'exec-1', 'npm run dev'), lead: 'working', + live: 'working', + parent: { stateName: 'working' }, check: () => expect(byDescription('npm run dev')).toMatchObject({ membership: 'live', @@ -263,6 +281,8 @@ describe('Codex structured child-work producer', () => { { frame: shellFrame('item/completed', REVIEWER, 'r1', 'cmd-1', 'npm test'), lead: 'working', + live: 'working', + parent: { stateName: 'working' }, check: () => { // A finished command leaves nothing behind. expect(byDescription('npm test')).toBeUndefined() @@ -279,32 +299,39 @@ describe('Codex structured child-work producer', () => { id: 'msg-1', text: 'Dev server is up' }), - lead: 'working' + lead: 'working', + live: 'working', + parent: { stateName: 'working' } }, // The parent's turn ends first; its child keeps running. { frame: turn('turn/completed', THREAD_ID, 'p1'), lead: 'done', + live: 'working', + parent: { stateName: 'working' }, check: () => expect(byDescription('review')).toMatchObject({ membership: 'live', state: 'working' }) }, - // The legacy task list carries no child state, so only the records can say a child waits, - // and the shared fold ranks that wait above the parent's own state. + // Only the records say a child waits, and the shared fold ranks that wait above the parent's + // own state. { frame: status(REVIEWER, ['waitingOnApproval']), lead: 'done', - childWaits: true, - check: () => { - expect(byDescription('review')?.state).toBe('waiting') - expect(agentChildWorkLiveness(adapter.backgroundTaskState('session-1')?.tasks)).toBe( - 'working' - ) - } + live: 'waiting', + parent: { stateName: 'waiting' }, + check: () => expect(byDescription('review')?.state).toBe('waiting') + }, + { + frame: status(REVIEWER, []), + lead: 'done', + live: 'working', + parent: { stateName: 'working' } }, - { frame: status(REVIEWER, []), lead: 'done' }, { frame: turn('turn/completed', REVIEWER, 'r1'), lead: 'done', + live: 'monitoring', + parent: { stateName: 'working', workingMode: 'monitoring' }, check: () => { expect(byDescription('review')).toMatchObject({ membership: 'settled', @@ -319,11 +346,18 @@ describe('Codex structured child-work producer', () => { expect(display('review')).toBe('monitoring') } }, - { frame: turn('turn/started', THREAD_ID, 'p2'), lead: 'working' }, + { + frame: turn('turn/started', THREAD_ID, 'p2'), + lead: 'working', + live: 'monitoring', + parent: { stateName: 'working' } + }, // The parent asks the finished child a follow-up: the same record, a new run. { frame: turn('turn/started', REVIEWER, 'r2'), lead: 'working', + live: 'working', + parent: { stateName: 'working' }, check: () => expect(byDescription('review')).toMatchObject({ childWorkId: 'child-1', @@ -332,10 +366,30 @@ describe('Codex structured child-work producer', () => { previousInvocations: [expect.objectContaining({ outcome: 'succeeded' })] }) }, - { frame: spawned(TESTER, 'test', 'p2'), lead: 'working' }, - { frame: turn('turn/started', TESTER, 't1'), lead: 'working' }, - { frame: spawned(LINTER, 'lint', 'p2'), lead: 'working' }, - { frame: turn('turn/started', LINTER, 'l1'), lead: 'working' }, + { + frame: spawned(TESTER, 'test', 'p2'), + lead: 'working', + live: 'working', + parent: { stateName: 'working' } + }, + { + frame: turn('turn/started', TESTER, 't1'), + lead: 'working', + live: 'working', + parent: { stateName: 'working' } + }, + { + frame: spawned(LINTER, 'lint', 'p2'), + lead: 'working', + live: 'working', + parent: { stateName: 'working' } + }, + { + frame: turn('turn/started', LINTER, 'l1'), + lead: 'working', + live: 'working', + parent: { stateName: 'working' } + }, // Codex ends this child's turn with an error it will not retry, and no turn/completed. { frame: { @@ -343,28 +397,41 @@ describe('Codex structured child-work producer', () => { params: { threadId: LINTER, turnId: 'l1', willRetry: false, error: { message: 'boom' } } }, lead: 'working', + live: 'working', + parent: { stateName: 'working' }, check: () => expect(byDescription('lint')).toMatchObject({ membership: 'settled', outcome: 'failed' }) }, { frame: turn('turn/completed', REVIEWER, 'r2', 'interrupted'), lead: 'working', + live: 'working', + parent: { stateName: 'working' }, check: () => expect(byDescription('review')).toMatchObject({ membership: 'settled', outcome: 'cancelled' }) }, - { frame: turn('turn/completed', THREAD_ID, 'p2'), lead: 'done' }, + { + frame: turn('turn/completed', THREAD_ID, 'p2'), + lead: 'done', + live: 'working', + parent: { stateName: 'working' } + }, { frame: turn('turn/completed', TESTER, 't1', 'failed'), lead: 'done', + live: 'monitoring', + parent: { stateName: 'working', workingMode: 'monitoring' }, check: () => expect(byDescription('test')).toMatchObject({ membership: 'settled', outcome: 'failed' }) }, { frame: shellFrame('item/completed', REVIEWER, 'r1', 'exec-1', 'npm run dev'), lead: 'done', + live: null, + parent: { stateName: 'done' }, check: () => { expect(byDescription('npm run dev')).toBeUndefined() expect(display('review')).toBe('interrupted') @@ -372,24 +439,32 @@ describe('Codex structured child-work producer', () => { } ] let journalMoments = 0 + let before = steps[0].parent for (const [index, step] of steps.entries()) { const frameMoments = momentsOf(step.frame) journalMoments += frameMoments.length - for (const [at, { recorded, legacy }] of frameMoments.entries()) { - expect({ index, at, parent: fold(step.lead, asStrip(recorded)) }).toEqual({ + // Every row the journal publishes mid-frame reads the parent from before the frame or after + // it, and never done while a command still runs. + for (const [at, { recorded, commandLive }] of frameMoments.entries()) { + const parent = fold(step.lead, recorded) + expect({ index, at, - parent: fold(step.lead, legacy) + parent: [before, step.parent].some((p) => isDeepStrictEqual(p, parent)) + }).toEqual({ index, at, parent: true }) + expect({ index, at, done: commandLive && parent.stateName === 'done' }).toEqual({ + index, + at, + done: false }) } - const legacy = agentChildWorkLiveness(adapter.backgroundTaskState('session-1')?.tasks) - const recorded = recordedLiveness() - const expected = step.childWaits ? 'waiting' : legacy - expect({ index, parent: fold(step.lead, recorded) }).toEqual({ + const live = recordedLiveness() + expect({ index, live, parent: fold(step.lead, live) }).toEqual({ index, - parent: fold(step.lead, expected) + live: step.live, + parent: step.parent }) - expect({ index, liveness: recorded }).toEqual({ index, liveness: expected }) + before = step.parent step.check?.() } expect(journalMoments).toBeGreaterThan(steps.length)