test(session): align turn-end contract artifacts

This commit is contained in:
_Kerman
2026-08-04 14:09:52 +08:00
parent d4fa26023d
commit e874910a76
186 changed files with 408 additions and 414 deletions

View File

@@ -201,7 +201,7 @@ describe('abort during tool execution ends the turn', () => {
|| event.type === 'step/start' || event.type === 'turn/end').map(event => event.type))
.toEqual(['turn/start', 'turn/end'])
expect(agent.session.events.find(event => event.type === 'turn/end')?.data)
.toEqual({ turn: 1, step: 0, reason: { kind: 'completed' } })
.toEqual({ turn: 1, reason: { kind: 'completed' } })
expect(agent.inbox.nextTurn).toHaveLength(0)
})
@@ -357,7 +357,7 @@ describe('plugin exceptions are contained', () => {
send(agent, 'first')
await waitForIdle(ctx, agent)
expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({
data: { step: 1, reason: { kind: 'error', error: { message: 'broken continuation plugin', code: 'UNKNOWN' } } },
data: { reason: { kind: 'error', error: { message: 'broken continuation plugin', code: 'UNKNOWN' } } },
})
// the loop is still alive: a second send works normally
@@ -601,7 +601,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', ()
const events = [...agent.session.events]
const turnEnd = events.find(event => event.type === 'turn/end')
expect(turnEnd).toMatchObject({ data: { step: 1, reason: { kind: 'error', error: failure } } })
expect(turnEnd).toMatchObject({ data: { reason: { kind: 'error', error: failure } } })
// A failed step must not synthesize an assistant message.
expect(events.some(event => event.type === 'assistant/message')).toBe(false)
})
@@ -781,7 +781,7 @@ describe('turn and step boundary recovery', () => {
errors: 1,
})
expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({
data: { step: 0, reason: { kind: 'error', error: { message: 'reject step-start before commit', code: 'UNKNOWN' } } },
data: { reason: { kind: 'error', error: { message: 'reject step-start before commit', code: 'UNKNOWN' } } },
})
})

View File

@@ -134,7 +134,7 @@ describe('thrown-value propagation', () => {
expect(starts).toHaveLength(0)
expect(ends).toHaveLength(0)
expect(messages).toHaveLength(0)
expect(agent.inbox.nextTurn).toHaveLength(1)
expect(agent.inbox.nextTurn).toHaveLength(2)
})
it('preserves non-Error throws from the agent/request waterfall', async () => {

View File

@@ -565,14 +565,15 @@ describe('agent loop', () => {
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(0)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'turn/end')).toHaveLength(1)
expect(agent.inbox.nextStep).toHaveLength(1)
send(agent, 'resume')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('pending steering')
})
@@ -867,11 +868,11 @@ describe('agent loop', () => {
send(agent, 'first')
await waitForIdle(ctx, agent)
// The first proposal failed before opening a turn or calling the model.
// The first proposal failed inside a balanced turn without calling the model.
expect(errors.map(error => error.message)).toEqual(['boom in pre-step'])
expect(adapter.requests.length).toBe(0)
expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(false)
expect(agent.session.events.some(event => event.type === 'turn/end')).toBe(false)
expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(true)
expect(agent.session.events.some(event => event.type === 'turn/end')).toBe(true)
// The loop survived: a second prompt runs a normal completed turn.
send(agent, 'second')

View File

@@ -360,7 +360,6 @@ describe('request stability across the loop', () => {
expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({
data: {
step: 1,
reason: failure instanceof LlmError
? { kind: 'error', error: failure.failure }
: { kind: 'error', error: { message: failure.message, code: 'UNKNOWN' } },

View File

@@ -44,7 +44,7 @@ async function persistSession(sessionId: SessionId): Promise<string> {
// the model merely to construct this lifecycle fixture.
const seed: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, step: 0, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
]
const session = ctx.sessions.create(sessionId, { seed })
await ctx.sessions.flush(session)
@@ -198,7 +198,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
await expect(ctx.agents.resume({ resumeSessionId: sessionId }))
.rejects.toThrow(/live turn is open/)
first.session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
first.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.sessions.flush(first.session)
const loaded = await ctx.sessionPersistence.load(sessionId)
expect(loaded.events.map(event => event.type)).toEqual(['turn/start', 'turn/end'])
@@ -548,7 +548,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
// materializes the fork (header + seed) on disk.
const seed: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, step: 0, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
]
const adapter1 = new MockAdapter([textResponse('a')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)

View File

@@ -683,7 +683,7 @@ describe('tool-call scheduler: failure quiescence', () => {
expect(turnEndBeforeDrain).toBeUndefined()
expect(gated.pending()).toEqual([])
expect(events(agent).findLast(event => event.type === 'turn/end')).toMatchObject({
data: { step: 1, reason: { kind: 'error', error: { message: schedulerError.message, code: 'UNKNOWN' } } },
data: { reason: { kind: 'error', error: { message: schedulerError.message, code: 'UNKNOWN' } } },
})
})
})

View File

@@ -93,7 +93,7 @@ describe('loop-level canonical tool order', () => {
expect(Object.isFrozen(adapter.requests[0])).toBe(true)
})
it('fails before opening a turn when toolOrder names an unregistered tool', async () => {
it('closes a no-step turn when toolOrder names an unregistered tool', async () => {
const adapter = new MockAdapter([textResponse('never sent')])
const ctx = await harness(adapter, ['ghost', TOOL_ORDER_REST])
registerNamed(ctx, 'alpha')
@@ -102,8 +102,8 @@ describe('loop-level canonical tool order', () => {
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
expect(foldRequestHeader(agent.session.events)).toBeUndefined()
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(false)
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(true)
expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true)
expect(agent.session.events.some(e => e.type === 'step/start')).toBe(false)
expect(agent.session.events.some(e => e.type === 'step/end')).toBe(false)
})

View File

@@ -29,7 +29,7 @@ function appendClosedTurn(
content: [{ type: 'text', text }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('turn/end', { turn, step: 0, reason })
session.append('turn/end', { turn, reason })
}
function appendOpenTurn(session: Session, turn: number): void {

View File

@@ -27,7 +27,7 @@ describe('session-log invariants', () => {
const session = ctx.sessions.create(SessionId('global-under-scoped-invariants'))
expect(() => {
session.append('turn/start', { turn: 1 })
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}).not.toThrow()
})
@@ -63,7 +63,7 @@ describe('session-log invariants', () => {
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}).not.toThrow()
})
@@ -82,7 +82,7 @@ describe('session-log invariants', () => {
expect(session.events).toEqual([])
expect(() => {
session.append('turn/start', { turn: 1 })
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}).not.toThrow()
})
@@ -94,7 +94,7 @@ describe('session-log invariants', () => {
ctx.on('session/event', () => { throw new Error('hostile observer') }, { prepend: true })
expect(() => {
session.append('turn/start', { turn: 1 })
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}).not.toThrow()
expect(warnings).toHaveLength(2)
})
@@ -112,7 +112,7 @@ describe('session-log invariants', () => {
type: 'turn/end',
seq: 0,
time: 2,
data: { turn: 1, step: 0, reason: { kind: 'completed' } },
data: { turn: 1, reason: { kind: 'completed' } },
} as never) }).toThrow(/seq must strictly increase/)
})
@@ -122,12 +122,12 @@ describe('session-log invariants', () => {
open.append('turn/start', { turn: 1 })
expect(() => open.append('turn/start', { turn: 2 }))
.toThrow(/turn 1 is still open/)
expect(() => open.append('turn/end', { turn: 2, step: 0, reason: { kind: 'completed' } }))
expect(() => open.append('turn/end', { turn: 2, reason: { kind: 'completed' } }))
.toThrow(/does not match open turn 1/)
const second = (await setup()).ctx.sessions.create()
second.append('turn/start', { turn: 1 })
second.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
second.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(() => second.append('turn/start', { turn: 3 }))
.toThrow(/expected turn 2, got 3/)
@@ -135,8 +135,8 @@ describe('session-log invariants', () => {
third.append('turn/start', { turn: 1 })
third.append('step/start', { turn: 1, step: 1 })
third.append('step/end', { turn: 1, step: 1 })
expect(() => third.append('turn/end', { turn: 1, step: 2, reason: { kind: 'completed' } }))
.toThrow(/expected last step 1, got 2/)
expect(() => third.append('turn/end', { turn: 1, reason: { kind: 'completed' } }))
.not.toThrow()
const enclosed = (await setup()).ctx.sessions.create()
enclosed.append('turn/start', { turn: 1 })
@@ -178,7 +178,7 @@ describe('session-log invariants', () => {
nested.append('turn/start', { turn: 1 })
nested.append('step/start', { turn: 1, step: 1 })
expect(() => nested.append('step/start', { turn: 1, step: 2 })).toThrow(/while step 1 is still open/)
expect(() => nested.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } }))
expect(() => nested.append('turn/end', { turn: 1, reason: { kind: 'completed' } }))
.toThrow(/while step 1 is still open/)
expect(() => nested.append('step/end', { turn: 1, step: 2 })).toThrow(/open is turn 1\/step 1/)
expect(() => nested.append('assistant/message', {
@@ -203,9 +203,8 @@ describe('session-log invariants', () => {
expect(() => skipped.append('turn/end', {
turn: 1,
step: 0,
reason: { kind: 'completed' },
})).toThrow(/expected last step 1, got 0/)
})).not.toThrow()
})
it('requires step-scoped stream and tool events to name the open step', async () => {
@@ -268,7 +267,7 @@ describe('session-log invariants', () => {
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('turn/start', { turn: 2 })
expect(() => session.append('tool/result', {
@@ -308,7 +307,7 @@ describe('session-log invariants', () => {
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(() => session.append('tool/result', {
...original.data,
@@ -341,7 +340,7 @@ describe('session-log invariants', () => {
error: { name: 'ToolNotStartedError', code: TOOL_NOT_STARTED },
}, { surfaceOp: 'append' })
repaired.append('step/end', { turn: 1, step: 1 })
repaired.append('turn/end', { turn: 1, step: 1, reason: { kind: 'interrupted' } })
repaired.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
}).not.toThrow()
const unresolved = (await setup()).ctx.sessions.create()
@@ -350,7 +349,7 @@ describe('session-log invariants', () => {
unresolved.append('step/start', { turn: 1, step: 1 })
unresolved.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
unresolved.append('step/end', { turn: 1, step: 1 })
unresolved.append('turn/end', { turn: 1, step: 1, reason: { kind: 'error', error: { message: 'boom', code: 'UNKNOWN' } } })
unresolved.append('turn/end', { turn: 1, reason: { kind: 'error', error: { message: 'boom', code: 'UNKNOWN' } } })
}).not.toThrow()
})
@@ -409,7 +408,7 @@ describe('session-log invariants', () => {
// Balanced seed: between turns.
expect(() => ctx.sessions.create(SessionId('inherited-between-turns'), { seed: [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, step: 0, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
] })).not.toThrow()
// Unbalanced seed: inside the open turn, which the relation permits.
const open = ctx.sessions.create(SessionId('inherited-inside-open-turn'), { seed: [
@@ -419,7 +418,7 @@ describe('session-log invariants', () => {
// Still open afterwards: the boundary moves no cursor.
expect(() => open.append('turn/start', { turn: 2 }))
.toThrow(/turn 1 is still open/)
expect(() => open.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })).not.toThrow()
expect(() => open.append('turn/end', { turn: 1, reason: { kind: 'completed' } })).not.toThrow()
})
it('removes all listeners when the companion is disposed', async () => {

View File

@@ -71,7 +71,7 @@ const messageEventArb: fc.Arbitrary<Appendable> = fc.oneof(
// A non-message event (trace/replay data — must NOT affect derived history).
const nonMessageEventArb: fc.Arbitrary<Appendable> = fc.oneof(
fc.constant<Appendable>({ type: 'turn/start', data: { turn: 1 } }),
fc.constant<Appendable>({ type: 'turn/end', data: { turn: 1, step: 0, reason: { kind: 'completed' } } }),
fc.constant<Appendable>({ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }),
fc.constant<Appendable>({ type: 'step/start', data: { turn: 1, step: 1 } }),
fc.constant<Appendable>({ type: 'step/end', data: { turn: 1, step: 1 } }),
fc.string().map((text): Appendable => ({ type: 'assistant/chunk', data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text } } })),

View File

@@ -19,7 +19,7 @@ describe('interruptedTurnClosers', () => {
it('returns nothing for a balanced log (ends on turn/end)', () => {
const balanced: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'turn/end', seq: 1, time: 1, data: { turn: 1, step: 0, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } },
]
expect(interruptedTurnClosers(balanced)).toEqual([])
})
@@ -169,7 +169,7 @@ describe('interruptedTurnClosers', () => {
}),
} },
{ type: 'step/end', seq: 4, time: 4, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 5, time: 5, data: { turn: 1, step: 1, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 5, time: 5, data: { turn: 1, reason: { kind: 'completed' } } },
userTurnStart(2, 6),
{ type: 'step/start', seq: 7, time: 7, data: { turn: 2, step: 1 } },
{ type: 'assistant/message', seq: 8, time: 8, data: {
@@ -285,7 +285,7 @@ describe('lastActivityTime', () => {
it('reports the log tail when no boundary is present', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'turn/end', seq: 1, time: 500, data: { turn: 1, step: 0, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 1, time: 500, data: { turn: 1, reason: { kind: 'completed' } } },
]
expect(lastActivityTime(events)).toBe(500)
})
@@ -293,7 +293,7 @@ describe('lastActivityTime', () => {
it('skips a trailing boundary in favour of the last real work', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'turn/end', seq: 1, time: 500, data: { turn: 1, step: 0, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 1, time: 500, data: { turn: 1, reason: { kind: 'completed' } } },
endSeedAt(2, 9_000),
]
// Resumed long after the work, but never worked in again.
@@ -304,7 +304,7 @@ describe('lastActivityTime', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
endSeedAt(1, 9_000),
{ type: 'turn/end', seq: 2, time: 9_500, data: { turn: 1, step: 0, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 2, time: 9_500, data: { turn: 1, reason: { kind: 'completed' } } },
]
expect(lastActivityTime(events)).toBe(9_500)
})

View File

@@ -48,7 +48,7 @@ describe('Session', () => {
isError: false,
}),
}, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const messages = session.deriveMessages()
expect(messages.map(m => m.role)).toEqual(['user', 'assistant', 'user'])
@@ -62,7 +62,7 @@ describe('Session', () => {
// append and persist like any other reason (JSON-serializable, no fields).
const session = new Session(SessionId('s1'))
session.append('turn/start', { turn: 1 })
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'max-tokens' } })
session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } })
const turnEnd = session.events.findLast(e => e.type === 'turn/end')!
expect(turnEnd.data.reason).toEqual({ kind: 'max-tokens' })
@@ -73,7 +73,7 @@ describe('Session', () => {
it('round-trips an aborted turn with its cancellation cause', () => {
const session = new Session(SessionId('aborted'))
session.append('turn/start', { turn: 1 })
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'aborted', reason: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } })
const replayed = new Session(SessionId('aborted-replay'), structuredClone(session.events))
expect(replayed.events.slice(0, -1)).toEqual(session.events)
const turnEnd = replayed.events.findLast(event => event.type === 'turn/end')
@@ -129,7 +129,7 @@ describe('Session', () => {
},
}),
}, { surfaceOp: 'append' })
original.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const replayed = new Session(SessionId('s3-replay'), [...original.events])
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
@@ -487,7 +487,7 @@ describe('Session', () => {
it('validates seed events: rejects a non-contiguous seq', () => {
const gapSeed = [
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1 } },
{ type: 'turn/end' as const, seq: 5, time: 2, data: { turn: 1, step: 0, reason: { kind: 'completed' as const } } }, // gap: expected seq 1
{ type: 'turn/end' as const, seq: 5, time: 2, data: { turn: 1, reason: { kind: 'completed' as const } } }, // gap: expected seq 1
] as SessionEvent[]
expect(() => new Session(SessionId('seed-gap'), gapSeed)).toThrow(/contiguous|seq/)
})
@@ -502,7 +502,7 @@ describe('Session', () => {
{ type: 'user/message' as const, seq: 1, time: 2, data: createUserMessage({
content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const },
}) },
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, step: 0, reason: { kind: 'completed' as const } } },
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
] as SessionEvent[]
expect(() => new Session(SessionId('seed-no-marker'), markerlessSeed)).toThrow(/requires a surfaceOp marker/)
})
@@ -513,7 +513,7 @@ describe('Session', () => {
{ type: 'user/message' as const, seq: 1, time: 2, data: createUserMessage({
content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const },
}), surfaceOp: 'append' as const },
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, step: 0, reason: { kind: 'completed' as const } } },
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
] as SessionEvent[]
const session = new Session(SessionId('seed-ok'), goodSeed)
expect(session.events.slice(0, 3)).toEqual(goodSeed)
@@ -702,7 +702,7 @@ describe('Session', () => {
role: 'user' as const,
content: [{ type: 'text' as const, text: 'original' }], source: { kind: 'user' as const },
}, surfaceOp: 'append' as const },
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, step: 0, reason: { kind: 'completed' as const } } },
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
] as SessionEvent[]
const session = new Session(SessionId('seed-snapshot'), seed)
// Mutate the ORIGINAL seed objects after construction: a shared reference
@@ -896,7 +896,7 @@ describe('Session', () => {
expect(() => { (before as SessionEvent[]).push(beforeEvent) }).toThrow(TypeError)
expect(() => { beforeEvent.data.turn = 99 }).toThrow(TypeError)
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const after = session.events
expect(before).toHaveLength(1)
expect(after).toHaveLength(2)
@@ -1632,7 +1632,7 @@ describe('todo/write event', () => {
const original = new Session(SessionId('t4'))
original.append('turn/start', { turn: 1 })
original.append('todo/write', { todos: [{ content: 'only', status: 'completed' }] })
original.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
// Seeding a non-surface event with no surfaceOp must not throw.
const replayed = new Session(SessionId('t4-replay'), [...original.events])
expect(replayed.events.findLast(e => e.type === 'todo/write')!.data.todos)

View File

@@ -36,7 +36,7 @@ function surfaceSession(): Session {
},
}),
}, { surfaceOp: 'append' })
s.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return s
}
@@ -401,7 +401,7 @@ describe('SurfaceManager', () => {
s.append('turn/start', { turn: 1 })
s.append('step/start', { turn: 1, step: 1 })
s.append('step/end', { turn: 1, step: 1 })
s.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(s.surface.nodes.length).toBe(0)
expect(s.deriveMessages()).toEqual([])
})
@@ -684,7 +684,7 @@ describe('deriveMessages with surface', () => {
},
}),
}, { surfaceOp: 'append' })
s.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
// Chunks and boundaries are NOT in the surface, so only 2 messages.
expect(s.deriveMessages()).toHaveLength(2)
})
@@ -772,7 +772,7 @@ describe('Session.append surface opts', () => {
}),
}, surfaceOp: 'append' },
{ type: 'step/end', seq: 3, time: 4, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 4, time: 5, data: { turn: 1, step: 1, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } },
]
const s = new Session(SessionId('nomessage'), seed)
// The empty assistant/message is on the surface but _deriveOneMessage returns null for it.

View File

@@ -100,7 +100,7 @@ describe('tool-pipeline invariants', () => {
expect(() => session.append('tool/code-dispatch-start', data)).toThrow(/outside any open turn/)
session.append('turn/start', { turn: 1 })
expect(() => session.append('tool/code-dispatch-start', data)).not.toThrow()
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
})
it('replays enclosed code-dispatch records on late registration', async () => {
@@ -116,7 +116,7 @@ describe('tool-pipeline invariants', () => {
isError: false,
content: [{ type: 'text', text: 'ok' }],
})
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.plugin(InvariantService)
await expect(ctx.plugin(ToolsInvariant).then(() => undefined)).resolves.toBeUndefined()
})