refactor(agent): simplify inbox-driven turn admission
This commit is contained in:
@@ -212,8 +212,9 @@ export class ReactLoopAgent implements Agent {
|
||||
try {
|
||||
while (true) {
|
||||
signal.throwIfAborted()
|
||||
const step = ++phase.step
|
||||
const step = phase.step + 1
|
||||
this.session.append('step/start', { turn, step })
|
||||
phase.step = step
|
||||
try {
|
||||
for (const message of decision.messages) {
|
||||
this.session.append('user/message', message, { surfaceOp: 'append' })
|
||||
@@ -249,7 +250,7 @@ export class ReactLoopAgent implements Agent {
|
||||
} finally {
|
||||
try {
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion -- every exit assigns a turn ending
|
||||
this.session.append('turn/end', { turn, reason: turnEnds! })
|
||||
this.session.append('turn/end', { turn, step: phase.step, reason: turnEnds! })
|
||||
} catch (error: unknown) {
|
||||
this.throwError(error)
|
||||
}
|
||||
@@ -262,11 +263,10 @@ export class ReactLoopAgent implements Agent {
|
||||
const { turn, step, abort: { signal } } = this.phase
|
||||
signal.throwIfAborted()
|
||||
const system = renderPrompt(assembly)
|
||||
const boundaryMessages = this.session.deriveMessages()
|
||||
|
||||
while (true) {
|
||||
const { request, preparedCall } = await this.buildRequest(
|
||||
turn, step, assembly.tools, system, boundaryMessages, signal,
|
||||
turn, step, assembly.tools, system, this.session.deriveMessages(), signal,
|
||||
)
|
||||
const assembler = new BlockAssembler()
|
||||
const chunkSeqs: number[] = []
|
||||
|
||||
@@ -385,7 +385,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: { reason: { kind: 'error', error: 'broken continuation plugin' } },
|
||||
data: { step: 1, reason: { kind: 'error', error: 'broken continuation plugin' } },
|
||||
})
|
||||
|
||||
// the loop is still alive: a second send works normally
|
||||
@@ -629,7 +629,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?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'error', error: failure })
|
||||
expect(turnEnd).toMatchObject({ data: { step: 1, reason: { kind: 'error', error: failure } } })
|
||||
// A failed step must not synthesize an assistant message.
|
||||
expect(events.some(event => event.type === 'assistant/message')).toBe(false)
|
||||
})
|
||||
@@ -808,7 +808,7 @@ describe('turn and step boundary recovery', () => {
|
||||
errors: 1,
|
||||
})
|
||||
expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({
|
||||
data: { reason: { kind: 'error', error: 'reject step-start before commit' } },
|
||||
data: { step: 0, reason: { kind: 'error', error: 'reject step-start before commit' } },
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -360,6 +360,7 @@ describe('request stability across the loop', () => {
|
||||
|
||||
expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({
|
||||
data: {
|
||||
step: 1,
|
||||
reason: {
|
||||
kind: 'error',
|
||||
error: failure instanceof LlmError ? failure.failure : failure.message,
|
||||
|
||||
@@ -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, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, step: 0, reason: { kind: 'completed' } } },
|
||||
]
|
||||
const session = ctx.sessions.create(sessionId, { seed })
|
||||
await ctx.sessions.flush(session)
|
||||
@@ -108,7 +108,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{ type: 'step/end', seq: 4, time: 5, data: { turn: 1, step: 1 } },
|
||||
{ type: 'turn/end', seq: 5, time: 6, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 5, time: 6, data: { turn: 1, step: 1, reason: { kind: 'completed' } } },
|
||||
] as unknown as SessionEvent[])
|
||||
await first.ctx.fiber.dispose()
|
||||
|
||||
@@ -181,7 +181,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, reason: { kind: 'completed' } })
|
||||
first.session.append('turn/end', { turn: 1, step: 0, 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'])
|
||||
@@ -496,7 +496,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, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, step: 0, reason: { kind: 'completed' } } },
|
||||
]
|
||||
const adapter1 = new MockAdapter([textResponse('a')])
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
|
||||
@@ -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: { reason: { kind: 'error', error: schedulerError.message } },
|
||||
data: { step: 1, reason: { kind: 'error', error: schedulerError.message } },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,7 +29,6 @@ function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
inject: () => {},
|
||||
reserveTurnAdmission: () => undefined,
|
||||
cancel() {},
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
|
||||
@@ -87,6 +87,10 @@ function validateEvent(
|
||||
if (trace.openStep !== null) {
|
||||
fail(`turn/end ${event.data.turn} while step ${trace.openStep} is still open`)
|
||||
}
|
||||
const lastStep = trace.nextStep - 1
|
||||
if (event.data.step !== lastStep) {
|
||||
fail(`turn/end ${event.data.turn} expected last step ${lastStep}, got ${event.data.step}`)
|
||||
}
|
||||
openTurn = null
|
||||
nextTurn += 1
|
||||
break
|
||||
|
||||
@@ -46,6 +46,7 @@ export const TOOL_OUTCOME_UNKNOWN = 'TOOL_OUTCOME_UNKNOWN'
|
||||
export function interruptedTurnClosers(events: readonly SessionEvent[]): SessionEvent[] {
|
||||
let openTurn: number | null = null
|
||||
let openStep: number | null = null
|
||||
let lastStep = 0
|
||||
// Reset at each turn boundary so earlier calls cannot leak into tail repair.
|
||||
// Assistant blocks register calls; later tool/call events add provenance seqs.
|
||||
const pendingCalls = new Map<CallId, { step: number; callSeq?: number }>()
|
||||
@@ -54,15 +55,18 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
|
||||
case 'turn/start':
|
||||
openTurn = event.data.turn
|
||||
openStep = null
|
||||
lastStep = 0
|
||||
pendingCalls.clear()
|
||||
break
|
||||
case 'turn/end':
|
||||
openTurn = null
|
||||
openStep = null
|
||||
lastStep = 0
|
||||
pendingCalls.clear()
|
||||
break
|
||||
case 'step/start':
|
||||
openStep = event.data.step
|
||||
lastStep = event.data.step
|
||||
break
|
||||
case 'step/end':
|
||||
pendingCalls.clear()
|
||||
@@ -147,6 +151,6 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
|
||||
if (openStep !== null) {
|
||||
closers.push({ type: 'step/end', seq: seq++, time, data: { turn: openTurn, step: openStep } })
|
||||
}
|
||||
closers.push({ type: 'turn/end', seq: seq++, time, data: { turn: openTurn, reason: { kind: 'interrupted' } } })
|
||||
closers.push({ type: 'turn/end', seq: seq++, time, data: { turn: openTurn, step: lastStep, reason: { kind: 'interrupted' } } })
|
||||
return closers
|
||||
}
|
||||
|
||||
@@ -183,12 +183,13 @@ export interface SessionEventMap {
|
||||
*/
|
||||
'turn/start': { turn: number }
|
||||
/**
|
||||
* Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop
|
||||
* awaits `session/flush` after an ordinary turn ends before claiming the next
|
||||
* queued item. Success commits the turn; rejection is reported live and does
|
||||
* not prevent later work.
|
||||
* Closes turn `turn` after `step`, the last entered step (`0` when none),
|
||||
* with the {@link TurnEndReason} that ended it. The loop awaits
|
||||
* `session/flush` after an ordinary turn ends before claiming the next queued
|
||||
* item. Success commits the turn; rejection is reported live and does not
|
||||
* prevent later work.
|
||||
*/
|
||||
'turn/end': { turn: number; reason: TurnEndReason }
|
||||
'turn/end': { turn: number; step: number; reason: TurnEndReason }
|
||||
/** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */
|
||||
'step/start': { turn: number; step: number }
|
||||
/** Closes step `step` of turn `turn`. */
|
||||
|
||||
@@ -29,7 +29,7 @@ function appendClosedTurn(
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason })
|
||||
session.append('turn/end', { turn, step: 0, reason })
|
||||
}
|
||||
|
||||
function appendOpenTurn(session: Session, turn: number): void {
|
||||
|
||||
@@ -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, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, step: 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, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } },
|
||||
data: { turn: 1, step: 0, 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, reason: { kind: 'completed' } }))
|
||||
expect(() => open.append('turn/end', { turn: 2, step: 0, 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, reason: { kind: 'completed' } })
|
||||
second.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
expect(() => second.append('turn/start', { turn: 3 }))
|
||||
.toThrow(/expected turn 2, got 3/)
|
||||
|
||||
@@ -166,7 +166,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, reason: { kind: 'completed' } }))
|
||||
expect(() => nested.append('turn/end', { turn: 1, step: 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', {
|
||||
@@ -250,7 +250,7 @@ describe('session-log invariants', () => {
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
|
||||
|
||||
session.append('turn/start', { turn: 2 })
|
||||
expect(() => session.append('tool/result', {
|
||||
@@ -290,7 +290,7 @@ describe('session-log invariants', () => {
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
|
||||
|
||||
expect(() => session.append('tool/result', {
|
||||
...original.data,
|
||||
@@ -323,7 +323,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, reason: { kind: 'interrupted' } })
|
||||
repaired.append('turn/end', { turn: 1, step: 1, reason: { kind: 'interrupted' } })
|
||||
}).not.toThrow()
|
||||
|
||||
const unresolved = (await setup()).ctx.sessions.create()
|
||||
@@ -332,7 +332,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, reason: { kind: 'error', error: 'boom' } })
|
||||
unresolved.append('turn/end', { turn: 1, step: 1, reason: { kind: 'error', error: 'boom' } })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
@@ -391,7 +391,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, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, step: 0, 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: [
|
||||
@@ -401,7 +401,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, reason: { kind: 'completed' } })).not.toThrow()
|
||||
expect(() => open.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })).not.toThrow()
|
||||
})
|
||||
|
||||
it('removes all listeners when the companion is disposed', async () => {
|
||||
|
||||
@@ -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, reason: { kind: 'completed' } } }),
|
||||
fc.constant<Appendable>({ type: 'turn/end', data: { turn: 1, step: 0, 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 } } })),
|
||||
|
||||
@@ -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, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 1, time: 1, data: { turn: 1, step: 0, 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, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 5, time: 5, data: { turn: 1, step: 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, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 1, time: 500, data: { turn: 1, step: 0, 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, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 1, time: 500, data: { turn: 1, step: 0, 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, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 2, time: 9_500, data: { turn: 1, step: 0, reason: { kind: 'completed' } } },
|
||||
]
|
||||
expect(lastActivityTime(events)).toBe(9_500)
|
||||
})
|
||||
|
||||
@@ -121,7 +121,7 @@ describe('Session.requestContext', () => {
|
||||
/** A turn-enclosed capacity record; the invariant rejects one outside a turn. */
|
||||
function seedWith(...records: { provider: string; model: string; contextWindow?: number }[]): SessionEvent[] {
|
||||
const events: SessionEvent[] = [{
|
||||
type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
type: 'turn/start', seq: 0, time: 1, data: { turn: 1 },
|
||||
}]
|
||||
for (const data of records) {
|
||||
events.push({ type: 'request/context', seq: events.length, time: 1, data })
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('Session', () => {
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, step: 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, reason: { kind: 'max-tokens' } })
|
||||
session.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'aborted', reason: { kind: 'user' } } })
|
||||
session.append('turn/end', { turn: 1, step: 0, 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')
|
||||
@@ -132,7 +132,7 @@ describe('Session', () => {
|
||||
},
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
original.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
|
||||
|
||||
const replayed = new Session(SessionId('s3-replay'), [...original.events])
|
||||
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
|
||||
@@ -493,7 +493,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, reason: { kind: 'completed' as const } } }, // gap: expected seq 1
|
||||
{ type: 'turn/end' as const, seq: 5, time: 2, data: { turn: 1, step: 0, reason: { kind: 'completed' as const } } }, // gap: expected seq 1
|
||||
] as SessionEvent[]
|
||||
expect(() => new Session(SessionId('seed-gap'), gapSeed)).toThrow(/contiguous|seq/)
|
||||
})
|
||||
@@ -508,7 +508,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, reason: { kind: 'completed' as const } } },
|
||||
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, step: 0, reason: { kind: 'completed' as const } } },
|
||||
] as SessionEvent[]
|
||||
expect(() => new Session(SessionId('seed-no-marker'), markerlessSeed)).toThrow(/requires a surfaceOp marker/)
|
||||
})
|
||||
@@ -519,7 +519,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, reason: { kind: 'completed' as const } } },
|
||||
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, step: 0, reason: { kind: 'completed' as const } } },
|
||||
] as SessionEvent[]
|
||||
const session = new Session(SessionId('seed-ok'), goodSeed)
|
||||
expect(session.events.slice(0, 3)).toEqual(goodSeed)
|
||||
@@ -708,7 +708,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, reason: { kind: 'completed' as const } } },
|
||||
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, step: 0, 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
|
||||
@@ -902,7 +902,7 @@ describe('Session', () => {
|
||||
expect(() => { (before as SessionEvent[]).push(beforeEvent) }).toThrow(TypeError)
|
||||
expect(() => { beforeEvent.data.turn = 99 }).toThrow(TypeError)
|
||||
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
const after = session.events
|
||||
expect(before).toHaveLength(1)
|
||||
expect(after).toHaveLength(2)
|
||||
@@ -1636,7 +1636,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, reason: { kind: 'completed' } })
|
||||
original.append('turn/end', { turn: 1, step: 0, 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)
|
||||
|
||||
@@ -36,7 +36,7 @@ function surfaceSession(): Session {
|
||||
},
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
s.append('turn/end', { turn: 1, step: 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, reason: { kind: 'completed' } })
|
||||
s.append('turn/end', { turn: 1, step: 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, reason: { kind: 'completed' } })
|
||||
s.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
|
||||
// Chunks and boundaries are NOT in the surface, so only 2 messages.
|
||||
expect(s.deriveMessages()).toHaveLength(2)
|
||||
})
|
||||
@@ -775,7 +775,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, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 4, time: 5, data: { turn: 1, step: 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.
|
||||
|
||||
@@ -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, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
await ctx.plugin(InvariantService)
|
||||
await expect(ctx.plugin(ToolsInvariant).then(() => undefined)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user