test(session): align turn-end contract artifacts
This commit is contained in:
@@ -225,7 +225,7 @@ describe('inspectRequests', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1, 'turn/end', {
|
||||
turn: 1, step: 1, reason: { kind: 'error', error: {
|
||||
turn: 1, reason: { kind: 'error', error: {
|
||||
code: 'AUTH',
|
||||
message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
|
||||
},
|
||||
@@ -233,7 +233,7 @@ describe('inspectRequests', () => {
|
||||
}),
|
||||
at(2, 'step/start', { turn: 2, step: 1 }),
|
||||
at(3, 'turn/end', {
|
||||
turn: 2, step: 1, reason: { kind: 'error', error: { message: 'plugin exploded', code: 'UNKNOWN' } },
|
||||
turn: 2, reason: { kind: 'error', error: { message: 'plugin exploded', code: 'UNKNOWN' } },
|
||||
}),
|
||||
]))
|
||||
|
||||
|
||||
@@ -222,7 +222,7 @@ describe('live event path', () => {
|
||||
ev.stepStart(8, 1),
|
||||
at(9, {
|
||||
type: 'turn/end',
|
||||
data: { turn: 1, step: 0, reason: { kind: 'error', error: {
|
||||
data: { turn: 1, reason: { kind: 'error', error: {
|
||||
code: 'AUTH',
|
||||
message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
|
||||
},
|
||||
@@ -234,7 +234,7 @@ describe('live event path', () => {
|
||||
ev.stepStart(12, 2, 1),
|
||||
at(13, {
|
||||
type: 'turn/end',
|
||||
data: { turn: 2, step: 1, reason: { kind: 'error', error: { message: 'plugin exploded', code: 'UNKNOWN' } } },
|
||||
data: { turn: 2, reason: { kind: 'error', error: { message: 'plugin exploded', code: 'UNKNOWN' } } },
|
||||
}),
|
||||
]
|
||||
for (const event of failedTurns) feed(event)
|
||||
@@ -411,7 +411,7 @@ describe('live event path', () => {
|
||||
feed(ev.retry(7, 1))
|
||||
feed(at(8, {
|
||||
type: 'turn/end',
|
||||
data: { turn: 1, step: 0, reason: { kind: 'error', error: { message: 'retry failed', code: 'UNKNOWN' } } },
|
||||
data: { turn: 1, reason: { kind: 'error', error: { message: 'retry failed', code: 'UNKNOWN' } } },
|
||||
}))
|
||||
|
||||
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||
|
||||
@@ -130,7 +130,7 @@ function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session {
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn, step: 1 })
|
||||
session.append('turn/end', { turn, step: 1, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
session.append('turn/start', {
|
||||
turn: turns + 1,
|
||||
@@ -180,7 +180,7 @@ function toolConversation(): Session {
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn, step: 1 })
|
||||
session.append('turn/end', { turn, step: 1, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
session.append('turn/start', { turn: 4 })
|
||||
return session
|
||||
@@ -226,7 +226,7 @@ function oversizedToolResult(chars = 3_000, withCompactablePrompt = false): Sess
|
||||
meta: { presentation: 'preserved' },
|
||||
}, { 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 })
|
||||
return session
|
||||
}
|
||||
@@ -935,7 +935,7 @@ describe('compaction region transaction', () => {
|
||||
it('requires an open turn and an idle compaction bracket', async () => {
|
||||
const compact = service()
|
||||
const closed = conversation(1)
|
||||
closed.append('turn/end', { turn: 2, step: 0, reason: { kind: 'completed' } })
|
||||
closed.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
const nodes = closed.surface.nodes
|
||||
await expect(compact.compactRegion(
|
||||
nodes[0]!,
|
||||
|
||||
@@ -209,7 +209,7 @@ function overflowHistorySeed(): SessionEvent[] {
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn, step: 1 })
|
||||
session.append('turn/end', { turn, step: 1, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
return [...session.events]
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ function closedConversation(turns = 2, lastTurnNumber = turns): Session {
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn, step: 1 })
|
||||
session.append('turn/end', { turn, step: 1, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
return session
|
||||
}
|
||||
@@ -437,7 +437,7 @@ describe('compactNow transaction and failure classification', () => {
|
||||
const original = closedConversation(2)
|
||||
original.append('compact/start', { turn: null })
|
||||
original.append('turn/start', { turn: 3 })
|
||||
original.append('turn/end', { turn: 3, step: 0, reason: { kind: 'interrupted' } })
|
||||
original.append('turn/end', { turn: 3, reason: { kind: 'interrupted' } })
|
||||
const reloaded = new Session(SessionId('reloaded-orphan'), [...original.events])
|
||||
const agent = fakeAgent(reloaded, () => () => undefined)
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ function appendToolStep(
|
||||
...extra,
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn, step: 1 })
|
||||
session.append('turn/end', { turn, step: 1, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
return result.seq
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('compaction invariants', () => {
|
||||
|
||||
expect(() => replayed.append(
|
||||
'turn/end',
|
||||
{ turn: 1, step: 0, reason: { kind: 'interrupted' } },
|
||||
{ turn: 1, reason: { kind: 'interrupted' } },
|
||||
)).not.toThrow()
|
||||
})
|
||||
|
||||
@@ -100,7 +100,7 @@ describe('compaction invariants', () => {
|
||||
const source = new Session(SessionId('stale-repaired-compaction-source'))
|
||||
source.append('compact/start', { turn: null })
|
||||
startTurn(source)
|
||||
source.append('turn/end', { turn: 1, step: 0, reason: { kind: 'interrupted' } })
|
||||
source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
|
||||
const replayed = ctx.sessions.create(SessionId('stale-repaired-compaction-replay'), {
|
||||
seed: source.events,
|
||||
})
|
||||
@@ -116,7 +116,7 @@ describe('compaction invariants', () => {
|
||||
|
||||
expect(() => {
|
||||
startTurn(replayed, 2)
|
||||
replayed.append('turn/end', { turn: 2, step: 0, reason: { kind: 'completed' } })
|
||||
replayed.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
@@ -126,7 +126,7 @@ describe('compaction invariants', () => {
|
||||
const source = new Session(SessionId('closed-nested-compaction-source'))
|
||||
source.append('compact/start', { turn: null })
|
||||
startTurn(source)
|
||||
source.append('turn/end', { turn: 1, step: 0, reason: { kind: 'interrupted' } })
|
||||
source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
|
||||
source.append('compact/end', { turn: null, error: 'failed after crossing turn' })
|
||||
const replayed = ctx.sessions.create(SessionId('closed-nested-compaction-replay'), {
|
||||
seed: source.events,
|
||||
@@ -147,7 +147,7 @@ describe('compaction invariants', () => {
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(CompactInvariant)
|
||||
expect(() => session.append('compact/end', { turn: 1, error: 'resume failed' })).not.toThrow()
|
||||
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
})
|
||||
|
||||
it('adopts a bare session and ignores unrelated committed events', async () => {
|
||||
@@ -200,7 +200,7 @@ describe('compaction invariants', () => {
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create()
|
||||
startTurn(session)
|
||||
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
session.append('compact/start', { turn: 1 })
|
||||
await ctx.plugin(InvariantService)
|
||||
await expect(ctx.plugin(CompactInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/)
|
||||
@@ -215,7 +215,7 @@ describe('compaction invariants', () => {
|
||||
standalone.append('compact/end', { turn: null, error: 'cancelled' })
|
||||
expect(() => {
|
||||
startTurn(standalone)
|
||||
standalone.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
standalone.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}).not.toThrow()
|
||||
|
||||
const numbered = ctx.sessions.create()
|
||||
@@ -223,12 +223,12 @@ describe('compaction invariants', () => {
|
||||
numbered.append('compact/start', { turn: 1 })
|
||||
expect(() => numbered.append(
|
||||
'turn/end',
|
||||
{ turn: 1, step: 0, reason: { kind: 'completed' } },
|
||||
{ turn: 1, reason: { kind: 'completed' } },
|
||||
)).toThrow(/turn\/end cannot cross an open compaction for turn 1/)
|
||||
numbered.append('compact/end', { turn: 1, error: 'cancelled' })
|
||||
expect(() => numbered.append(
|
||||
'turn/end',
|
||||
{ turn: 1, step: 0, reason: { kind: 'completed' } },
|
||||
{ turn: 1, reason: { kind: 'completed' } },
|
||||
)).not.toThrow()
|
||||
})
|
||||
|
||||
|
||||
@@ -271,7 +271,7 @@ describe('tool-pairing cache refresh', () => {
|
||||
expect(eventIndexReads).toBe(3)
|
||||
|
||||
events.push({
|
||||
type: 'turn/end', seq: 3, time: 3, data: { turn: 1, step: 0, reason: { kind: 'completed' } },
|
||||
type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } },
|
||||
})
|
||||
expect(toolPairingBalancedAfter(session, nodes[2]!)).toBe(true)
|
||||
expect(eventCollectionReads).toBe(1)
|
||||
|
||||
@@ -47,7 +47,7 @@ function preparing(turn: number, step: number): Session {
|
||||
const session = new Session(SessionId(`time-invariant-${turn}-${step}`))
|
||||
for (let priorTurn = 1; priorTurn < turn; priorTurn += 1) {
|
||||
session.append('turn/start', { turn: priorTurn })
|
||||
session.append('turn/end', { turn: priorTurn, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: priorTurn, reason: { kind: 'completed' } })
|
||||
}
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
@@ -127,7 +127,7 @@ describe('time-context invariants', () => {
|
||||
it('rejects a reading after cancellation closes the turn', async () => {
|
||||
const ctx = await setup()
|
||||
const session = preparing(1, 2)
|
||||
session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } })
|
||||
expect(() => { ctx.emit('session/event', session, event(reading('1', '2', 'step context'))) })
|
||||
.toThrow(/at a prompt boundary/)
|
||||
})
|
||||
|
||||
@@ -248,7 +248,7 @@ describe('durable step context', () => {
|
||||
surfaceOp: { op: 'replace', start: user.seq, end: reading.seq },
|
||||
sourceEventSeqs: [user.seq, reading.seq],
|
||||
})
|
||||
original.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
expect(JSON.stringify(original.deriveMessages())).not.toContain('Time sampled while preparing')
|
||||
|
||||
const resumed = new Session(SessionId('resumed'), [...original.events])
|
||||
@@ -277,7 +277,7 @@ describe('durable step context', () => {
|
||||
const firstAgent = sessionAgent(first, 'first-agent')
|
||||
openMessageTurn(first, 1)
|
||||
await fire(ctx, firstAgent, 1, 1)
|
||||
first.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
first.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
vi.setSystemTime(BASE + 500)
|
||||
openMessageTurn(first, 2)
|
||||
|
||||
@@ -210,12 +210,12 @@ describe('tmux-context injection', () => {
|
||||
|
||||
openMessageTurn(session, 1)
|
||||
await fire(ctx, agent, 1, 1)
|
||||
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
// Same state on turn 2: suppressed.
|
||||
openMessageTurn(session, 2)
|
||||
await fire(ctx, agent, 2, 1)
|
||||
session.append('turn/end', { turn: 2, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
expect(contextTexts(session)).toHaveLength(1)
|
||||
|
||||
// Moved pane on turn 3: re-injected.
|
||||
@@ -238,7 +238,7 @@ describe('tmux-context injection', () => {
|
||||
|
||||
openMessageTurn(session, 1)
|
||||
await fire(ctx, agent, 1, 1)
|
||||
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
// Changed state but inside the interval: suppressed, and never queried.
|
||||
bash.result = runResult(`${tmuxLine({ paneId: '%99' })}\n`)
|
||||
|
||||
@@ -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' } } },
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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' } },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' } } },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 } } })),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -440,7 +440,7 @@ describe('runOneShot and executeCli', () => {
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}))
|
||||
other.append('turn/start', { turn: 1 })
|
||||
other.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
other.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
})
|
||||
const streamed: { sessionId: string; event: SessionEvent }[] = []
|
||||
const result = runOneShot(ctx, {
|
||||
|
||||
@@ -260,7 +260,7 @@ describe('same-session goal driving', () => {
|
||||
message: 'Goal round was rejected before entering its step.',
|
||||
})
|
||||
expect(test.adapter.requests).toHaveLength(0)
|
||||
expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(false)
|
||||
expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not reserve again when a stopped-goal observer queues cancel-scoped work', async () => {
|
||||
@@ -744,7 +744,7 @@ describe('same-session goal driving', () => {
|
||||
await test.agent.whenIdle()
|
||||
|
||||
expect(test.adapter.requests).toHaveLength(0)
|
||||
expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(false)
|
||||
expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves round-zero goal context to the ordinary pre-step chain', async () => {
|
||||
@@ -1023,7 +1023,7 @@ describe('same-session goal driving', () => {
|
||||
|
||||
expect(test.ctx.goals.get(test.agent)).toMatchObject({ phase: 'active', roundsStarted: 0 })
|
||||
expect(test.adapter.requests).toHaveLength(0)
|
||||
expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(false)
|
||||
expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores session events without an exact owning agent and retires disposed agent state', async () => {
|
||||
@@ -1032,7 +1032,7 @@ describe('same-session goal driving', () => {
|
||||
orphan.append('turn/start', {
|
||||
turn: 1,
|
||||
})
|
||||
orphan.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
orphan.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
const handle = await test.ctx.agents.create({
|
||||
sessionId: SessionId('goal-session-disposed'),
|
||||
|
||||
@@ -41,7 +41,7 @@ function appendRound(session: Session, turn: number, content = renderGoalRoundPr
|
||||
session.append('user/message', createUserMessage({
|
||||
content, source,
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
async function mount(sessionFirst = false): Promise<{ ctx: Context; session: Session }> {
|
||||
@@ -73,7 +73,7 @@ describe('goal-session prompt invariants', () => {
|
||||
content: [{ type: 'text', text: 'ordinary human message' }],
|
||||
source: userSource,
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 4, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 4, reason: { kind: 'completed' } })
|
||||
|
||||
const stateSource = {
|
||||
kind: 'goal', goalId: change.goal.id, revision: change.goal.revision, round: 0,
|
||||
|
||||
@@ -74,7 +74,7 @@ function appendRound(session: Session, ref: GoalRef, round: number): void {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `round ${round}` }], source,
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
describe('GoalService creation and replay', () => {
|
||||
@@ -582,7 +582,7 @@ describe('goal replay validation', () => {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'ordinary' }], source,
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
expect(foldGoal(session.events)).toEqual({ roundsStarted: 0 })
|
||||
})
|
||||
|
||||
@@ -724,7 +724,7 @@ describe('goal replay validation', () => {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'missing' }], source,
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
expect(() => foldGoal(session.events)).toThrow('goal message source is invalid')
|
||||
})
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ function openTurn(stub: StubAgent, source: MessageSource, text = 'prompt'): numb
|
||||
|
||||
/** Close the currently open test turn. */
|
||||
function closeTurn(stub: StubAgent, turn: number): void {
|
||||
stub.session.append('turn/end', { turn, step: 0, reason: { kind: 'completed' } })
|
||||
stub.session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
async function harness(config: toolGoal.Config = {}) {
|
||||
|
||||
@@ -54,7 +54,7 @@ describe('hook-protocol invariants', () => {
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(HookInvariant)
|
||||
expect(() => session.append('hook/result', result())).not.toThrow()
|
||||
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
})
|
||||
|
||||
it('adopts a bare session first observed through publication', async () => {
|
||||
@@ -87,7 +87,7 @@ describe('hook-protocol invariants', () => {
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create()
|
||||
startTurn(session)
|
||||
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
session.append('hook/invoked', invoked())
|
||||
await ctx.plugin(InvariantService)
|
||||
await expect(ctx.plugin(HookInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/)
|
||||
|
||||
@@ -91,7 +91,7 @@ describe('attached updatedAt excludes end-seed', () => {
|
||||
const resumed = ctx.sessions.create(sid('resumed-untouched'), {
|
||||
seed: [
|
||||
{ type: 'turn/start', seq: 0, time: worked, data: { turn: 1 } },
|
||||
{ type: 'turn/end', seq: 1, time: worked, data: { turn: 1, step: 0, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 1, time: worked, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
],
|
||||
meta: { cwd: '/proj', createdAt: 500 },
|
||||
})
|
||||
|
||||
@@ -64,7 +64,7 @@ function liveAgent(
|
||||
content: [{ type: 'text', text: `prompt ${String(turn)}` }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
if (tail !== 'none') {
|
||||
session.append('turn/start', { turn: turns + 1 })
|
||||
@@ -74,7 +74,6 @@ function liveAgent(
|
||||
}), { surfaceOp: 'append' })
|
||||
if (tail === 'aborted') session.append('turn/end', {
|
||||
turn: turns + 1,
|
||||
step: 0,
|
||||
reason: { kind: 'aborted', reason: { kind: 'user' } },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ function liveAgent(ctx: Context, id: string, turns: number): Session {
|
||||
content: [{ type: 'text', text: `prompt ${String(turn)}` }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
|
||||
return session
|
||||
|
||||
@@ -316,7 +316,7 @@ describe('mux live view computation', () => {
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-late'), name: 'term', arguments: '{"cmd":"tail"}' })
|
||||
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
// The turn/end above cleared the live table; pairing must fall back to
|
||||
// scanning the session's in-memory events.
|
||||
session.append('tool/result', {
|
||||
|
||||
@@ -181,7 +181,7 @@ describe('llm-retry invariants', () => {
|
||||
|
||||
const closedTurn = openStep(ctx, 'retry-invariant-closed-turn')
|
||||
closedTurn.append('step/end', { turn: 1, step: 1 })
|
||||
closedTurn.append('turn/end', { turn: 1, step: 1, reason: { kind: 'aborted', reason: { kind: 'user' } },
|
||||
closedTurn.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } },
|
||||
})
|
||||
expect(() => {
|
||||
closedTurn.append('llm/retry', { turn: 1, step: 1, ...normal })
|
||||
@@ -228,7 +228,7 @@ describe('llm-retry invariants', () => {
|
||||
appendRetryTurn(missingEnd, 2)
|
||||
|
||||
const nonFailureEnd = ctx.sessions.create(SessionId('retry-invariant-non-failure-end'))
|
||||
nonFailureEnd.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
nonFailureEnd.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
nonFailureEnd.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'idle context' }],
|
||||
source: { kind: 'user' },
|
||||
@@ -236,7 +236,7 @@ describe('llm-retry invariants', () => {
|
||||
appendRetryTurn(nonFailureEnd, 2)
|
||||
|
||||
const missingStart = ctx.sessions.create(SessionId('retry-invariant-missing-start'))
|
||||
missingStart.append('turn/end', { turn: 1, step: 0, reason: { kind: 'error', error: failure },
|
||||
missingStart.append('turn/end', { turn: 1, reason: { kind: 'error', error: failure },
|
||||
})
|
||||
appendRetryTurn(missingStart, 2)
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ describe.each(['jsonl', 'sqlite'] as const)('%s retry-event persistence', (kind)
|
||||
failure: { message: 'provider busy', code: 'RATE_LIMIT', status: 429 },
|
||||
})
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'error', error: { message: 'provider busy', code: 'RATE_LIMIT', status: 429 },
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'error', error: { message: 'provider busy', code: 'RATE_LIMIT', status: 429 },
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -337,7 +337,7 @@ describe('provider-routed retry policy', () => {
|
||||
expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(2)
|
||||
expect(agent.session.events.at(-1)).toMatchObject({
|
||||
type: 'turn/end',
|
||||
data: { step: 1, reason: { kind: 'error', error: { message: 'busy three', code: 'SERVER' } } },
|
||||
data: { reason: { kind: 'error', error: { message: 'busy three', code: 'SERVER' } } },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -451,7 +451,7 @@ describe('provider-routed retry policy', () => {
|
||||
const end = agent.session.events.at(-1)
|
||||
expect(end).toMatchObject({
|
||||
type: 'turn/end',
|
||||
data: { step: 1, reason: { kind: 'error', error: { code: 'NO_ADAPTER' } } },
|
||||
data: { reason: { kind: 'error', error: { code: 'NO_ADAPTER' } } },
|
||||
})
|
||||
if (end?.type === 'turn/end' && end.data.reason.kind === 'error') {
|
||||
expect(end.data.reason.error.message).toContain('no adapter registered for provider')
|
||||
|
||||
@@ -193,7 +193,7 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => {
|
||||
expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false)
|
||||
expect(agent.session.events.at(-1)).toMatchObject({
|
||||
type: 'turn/end',
|
||||
data: { step: 1, reason: { kind: 'error', error: { message: 'SSE stream ended without [DONE]', code: 'STREAM_CLOSED' } } },
|
||||
data: { reason: { kind: 'error', error: { message: 'SSE stream ended without [DONE]', code: 'STREAM_CLOSED' } } },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -236,7 +236,7 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => {
|
||||
const end = agent.session.events.at(-1)
|
||||
expect(end).toMatchObject({
|
||||
type: 'turn/end',
|
||||
data: { step: 1, reason: { kind: 'error', error: { code: 'TRANSPORT' } } },
|
||||
data: { reason: { kind: 'error', error: { code: 'TRANSPORT' } } },
|
||||
})
|
||||
if (end?.type === 'turn/end' && end.data.reason.kind === 'error') {
|
||||
expect(end.data.reason.error.message).toContain('DeepSeek API request to')
|
||||
|
||||
@@ -31,7 +31,7 @@ describe('plan-mode stream invariants', () => {
|
||||
expect(() => { ctx.emit('session/event', session, event(true)) }).not.toThrow()
|
||||
expect(() => { ctx.emit('session/event', session, event(false)) }).not.toThrow()
|
||||
ctx.emit('session/event', session, {
|
||||
type: 'turn/end', seq: 3, time: 3, data: { turn: 1, step: 0, reason: { kind: 'completed' } },
|
||||
type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('plan-mode stream invariants', () => {
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('plan/mode', { active: 'plan' as unknown as boolean })
|
||||
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
|
||||
await expect(ctx.plugin(PlanModeInvariant).then(() => undefined)).rejects.toThrow(/expected a boolean/)
|
||||
@@ -78,7 +78,7 @@ describe('plan-mode stream invariants', () => {
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('plan/mode', { active: true })
|
||||
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
|
||||
await expect(ctx.plugin(PlanModeInvariant).then(() => undefined)).resolves.toBeUndefined()
|
||||
|
||||
@@ -96,7 +96,7 @@ function openTurn(session: Session, turn = 0): void {
|
||||
|
||||
/** Close the open turn (the between-turns shape: selections commit immediately). */
|
||||
function closeTurn(session: Session, turn = 0): void {
|
||||
session.append('turn/end', { turn, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
/** Append a minimal `request/header` snapshot so the log has a "what the model was told" anchor. */
|
||||
|
||||
@@ -60,7 +60,7 @@ function runPlanCommand(session: Session, args: string, index: number): void {
|
||||
function commitPlanMode(session: Session, active: boolean, turn: number): void {
|
||||
session.append('turn/start', { turn })
|
||||
session.append('plan/mode', { active })
|
||||
session.append('turn/end', { turn, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
describe('plan projection unit', () => {
|
||||
|
||||
@@ -64,7 +64,7 @@ function appendClosedTurn(session: Session): void {
|
||||
content: [{ type: 'text', text: 'hello' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
// Run the shared backend contract against the real JSONL backend.
|
||||
@@ -224,7 +224,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
|
||||
}),
|
||||
}, surfaceOp: 'append', sourceEventSeqs: [2, 3] },
|
||||
{ type: 'step/end', seq: 5, time: 6, data: { turn: 1, step: 1 } },
|
||||
{ type: 'turn/end', seq: 6, time: 7, data: { turn: 1, step: 1, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 6, time: 7, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
await ctx.sessionPersistence.create(m)
|
||||
await ctx.sessionPersistence.append(m.id, log)
|
||||
@@ -344,7 +344,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
|
||||
JSON.stringify(toHeaderLine(m)),
|
||||
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }),
|
||||
JSON.stringify({ type: 'request/header-delta', seq: 1, time: 2, data: { config: { model: 'legacy' } } }),
|
||||
JSON.stringify({ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, step: 0, reason: { kind: 'completed' } } }),
|
||||
JSON.stringify({ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }),
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
@@ -419,7 +419,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
|
||||
// The next append continues at seq 10 (the balanced length).
|
||||
const turn3 = [
|
||||
{ type: 'turn/start', seq: 10, time: 11, data: { turn: 3 } },
|
||||
{ type: 'turn/end', seq: 11, time: 12, data: { turn: 3, step: 0, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 11, time: 12, data: { turn: 3, reason: { kind: 'completed' } } },
|
||||
] as SessionEvent[]
|
||||
await ctx.sessionPersistence.append(m.id, turn3)
|
||||
const reloaded = await ctx.sessionPersistence.load(m.id)
|
||||
@@ -438,7 +438,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
|
||||
await ctx.sessionPersistence.load(m.id)
|
||||
await ctx.sessionPersistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } },
|
||||
{ type: 'turn/end', seq: 7, time: 10, data: { turn: 2, step: 0, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
] as SessionEvent[])
|
||||
const after = await readFile(rawLogPath(root, undefined, m.id), 'utf8')
|
||||
// the committed prefix is byte-for-byte intact at the head of the file
|
||||
@@ -466,7 +466,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
|
||||
|
||||
const turn2 = [
|
||||
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } },
|
||||
{ type: 'turn/end', seq: 7, time: 10, data: { turn: 2, step: 0, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
] as SessionEvent[]
|
||||
// The append rejects, but the partial bytes are truncated back: the file is
|
||||
// its pre-append size and the cursor is unchanged.
|
||||
@@ -529,7 +529,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
|
||||
mutableHeader(loaded.meta).cwd = '/evil'
|
||||
await ctx.sessionPersistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } },
|
||||
{ type: 'turn/end', seq: 7, time: 10, data: { turn: 2, step: 0, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
] as SessionEvent[])
|
||||
// The append landed in the ORIGINAL /proj log, not beside an /evil path.
|
||||
const reloaded = await ctx.sessionPersistence.load(m.id)
|
||||
@@ -606,8 +606,8 @@ describe('SessionPersistenceJsonl: write path (session/event → flush)', () =>
|
||||
b.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'B' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
a.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
b.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
a.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
b.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.sessions.flush(a)
|
||||
await ctx.sessions.flush(b)
|
||||
|
||||
@@ -694,7 +694,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => {
|
||||
JSON.stringify({ type: 'session', version: 0, id: 'g2', createdAt: 1, delegationDepth: 0 }),
|
||||
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }),
|
||||
JSON.stringify({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }), // gap: missing seq 1
|
||||
JSON.stringify({ type: 'turn/end', seq: 3, time: 3, data: { turn: 1, step: 1, reason: { kind: 'completed' } } }),
|
||||
JSON.stringify({ type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }),
|
||||
].join('\n') + '\n'
|
||||
// A turn/end exists, so the prefix up to it is committed — but it has a hole.
|
||||
// Truncating it would silently drop committed data → unloadable.
|
||||
@@ -705,7 +705,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => {
|
||||
const log = [
|
||||
JSON.stringify({ type: 'session', version: 0, id: 'c', createdAt: 1, delegationDepth: 0 }),
|
||||
'{not json', // corrupt, sits in the committed region (a turn/end follows)
|
||||
JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, step: 0, reason: { kind: 'completed' } } }),
|
||||
JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }),
|
||||
].join('\n') + '\n'
|
||||
expect(() => scanLog(Buffer.from(log))).toThrow(/unparsable committed event/)
|
||||
})
|
||||
@@ -733,7 +733,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => {
|
||||
const log = [
|
||||
JSON.stringify({ type: 'session', version: 0, id: 't', createdAt: 1, delegationDepth: 0 }),
|
||||
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }),
|
||||
JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, step: 0, reason: { kind: 'completed' } } }),
|
||||
JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }),
|
||||
JSON.stringify({ type: 'step/start', seq: 9, time: 3, data: { turn: 2, step: 1 } }), // gap in uncommitted tail
|
||||
].join('\n') + '\n'
|
||||
const { events } = scanLog(Buffer.from(log))
|
||||
@@ -777,7 +777,7 @@ describe('SessionPersistenceJsonl: default packed chunk rows', () => {
|
||||
}),
|
||||
}, surfaceOp: 'append', sourceEventSeqs: [2, 3, 4, 5, 6] },
|
||||
{ type: 'step/end', seq: 8, time: 9, data: { turn: 1, step: 1 } },
|
||||
{ type: 'turn/end', seq: 9, time: 10, data: { turn: 1, step: 1, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 9, time: 10, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -855,7 +855,7 @@ describe('SessionPersistenceJsonl: default packed chunk rows', () => {
|
||||
JSON.stringify({ type: 'session', version: 0, id: 'rows', createdAt: 1, delegationDepth: 0 }),
|
||||
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }),
|
||||
JSON.stringify({ type: 'text-chunks', seq0: 1, time0: 2, data: { turn: 1, step: 1, index: 0, dt: [1, 1], texts: ['a', 'b', 'c'] } }),
|
||||
JSON.stringify({ type: 'turn/end', seq: 4, time: 5, data: { turn: 1, step: 1, reason: { kind: 'completed' } } }),
|
||||
JSON.stringify({ type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } }),
|
||||
].join('\n') + '\n'
|
||||
const { events } = scanLog(Buffer.from(logText))
|
||||
expect(events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4])
|
||||
@@ -867,7 +867,7 @@ describe('SessionPersistenceJsonl: default packed chunk rows', () => {
|
||||
JSON.stringify({ type: 'session', version: 0, id: 'bad-row', createdAt: 1, delegationDepth: 0 }),
|
||||
// dt arity mismatch — row validation throws, so the line is a committed hole.
|
||||
JSON.stringify({ type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], texts: ['a', 'b'] } }),
|
||||
JSON.stringify({ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, step: 1, reason: { kind: 'completed' } } }),
|
||||
JSON.stringify({ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }),
|
||||
].join('\n') + '\n'
|
||||
expect(() => scanLog(Buffer.from(logText))).toThrow(/unparsable committed event/)
|
||||
})
|
||||
@@ -1154,7 +1154,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
const a = inner.sessions.create(SessionId('bound'), { meta: { cwd: '/a' } })
|
||||
a.append('turn/start', { turn: 1 })
|
||||
a.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
a.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}, { inject: ['sessions'] }))
|
||||
for (const s of ctx.sessions.list()) await ctx.sessions.flush(s)
|
||||
await firstFiber.dispose()
|
||||
@@ -1232,7 +1232,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
|
||||
await ctx2.sessionPersistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } },
|
||||
{ type: 'turn/end', seq: 7, time: 10, data: { turn: 2, step: 0, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
] as SessionEvent[])
|
||||
const loaded = await ctx2.sessionPersistence.load(m.id)
|
||||
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
|
||||
@@ -1282,7 +1282,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
// Make the durable materialize fail on the next flush.
|
||||
const backend = ctx2.sessionPersistence as unknown as { materialize: (...args: unknown[]) => Promise<void> }
|
||||
const origMat = backend.materialize.bind(backend)
|
||||
|
||||
@@ -286,7 +286,7 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => {
|
||||
const before = await readFile(path)
|
||||
const secondTurn = [
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
|
||||
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, step: 0, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
] as SessionEvent[]
|
||||
await ctx.sessionPersistence.append(header.id, secondTurn)
|
||||
|
||||
@@ -428,7 +428,7 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => {
|
||||
const path = logPath(root, header.cwd, header.id, 'zstd')
|
||||
const secondTurn = [
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
|
||||
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, step: 0, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
] as SessionEvent[]
|
||||
const frame = await compressZstdFrame(secondTurn.map(e => JSON.stringify(e)).join('\n') + '\n')
|
||||
await appendFile(path, frame.subarray(0, -1))
|
||||
@@ -476,7 +476,7 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => {
|
||||
})
|
||||
const secondTurn = [
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
|
||||
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, step: 0, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
] as SessionEvent[]
|
||||
await expect(ctx.sessionPersistence.append(header.id, secondTurn)).rejects.toThrow(/simulated Zstandard fsync failure/)
|
||||
expect(await readFile(path)).toEqual(before)
|
||||
|
||||
@@ -135,7 +135,7 @@ describe('scanRows', () => {
|
||||
const gapped: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
{ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }, // seq 1 missing
|
||||
{ type: 'turn/end', seq: 3, time: 3, data: { turn: 1, step: 1, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
expect(() => scanRows(rows(gapped))).toThrow(/seq gap in committed region/)
|
||||
})
|
||||
@@ -143,7 +143,7 @@ describe('scanRows', () => {
|
||||
it('throws on an unparsable row inside the committed region', () => {
|
||||
const withCorruptCommitted: EventRow[] = [
|
||||
{ seq: 0, type: 'turn/start', time: 1, data: '{not json', source_event_seqs: null, surface_op: null }, // corrupt, sits before a turn/end
|
||||
{ seq: 1, type: 'turn/end', time: 2, data: JSON.stringify({ turn: 1, step: 0, reason: { kind: 'completed' } }), source_event_seqs: null, surface_op: null },
|
||||
{ seq: 1, type: 'turn/end', time: 2, data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }), source_event_seqs: null, surface_op: null },
|
||||
]
|
||||
expect(() => scanRows(withCorruptCommitted)).toThrow(/unparsable committed event/)
|
||||
})
|
||||
@@ -201,7 +201,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
const insert = db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)')
|
||||
insert.run(m.id, 0, 'turn/start', 1, JSON.stringify({ turn: 1 }))
|
||||
insert.run(m.id, 1, 'request/header-delta', 2, JSON.stringify({ config: { model: 'legacy' } }))
|
||||
insert.run(m.id, 2, 'turn/end', 3, JSON.stringify({ turn: 1, step: 0, reason: { kind: 'completed' } }))
|
||||
insert.run(m.id, 2, 'turn/end', 3, JSON.stringify({ turn: 1, reason: { kind: 'completed' } }))
|
||||
db.close()
|
||||
|
||||
const mounted = await backend(path)
|
||||
@@ -268,7 +268,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
// length (seq 10) and a reload round-trips identically.
|
||||
await ctx2.sessionPersistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 10, time: 9, data: { turn: 3 } },
|
||||
{ type: 'turn/end', seq: 11, time: 10, data: { turn: 3, step: 0, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 11, time: 10, data: { turn: 3, reason: { kind: 'completed' } } },
|
||||
])
|
||||
const reloaded = await ctx2.sessionPersistence.load(m.id)
|
||||
expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
|
||||
@@ -494,7 +494,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
// load physically deleted the corrupt tail row, so a fresh append continues.
|
||||
await b2.ctx.sessionPersistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 6, time: 8, data: { turn: 2 } },
|
||||
{ type: 'turn/end', seq: 7, time: 9, data: { turn: 2, step: 0, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 7, time: 9, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
])
|
||||
const reloaded = await b2.ctx.sessionPersistence.load(m.id)
|
||||
expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
|
||||
@@ -720,7 +720,7 @@ describe('SessionPersistenceSqlite: edge cases', () => {
|
||||
await b2.ctx.sessionPersistence.load(m.id) // cursor 6 in b2
|
||||
const turn2: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
|
||||
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, step: 0, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
]
|
||||
// b1 commits seq 6..7 first.
|
||||
await b1.ctx.sessionPersistence.append(m.id, turn2)
|
||||
@@ -815,7 +815,7 @@ describe('surface field round-trip', () => {
|
||||
data: JSON.stringify({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }),
|
||||
source_event_seqs: null, surface_op: '{"op":"replace","start":0,"end":0}' },
|
||||
{ seq: 1, type: 'turn/end', time: 2,
|
||||
data: JSON.stringify({ turn: 1, step: 0, reason: { kind: 'completed' } }),
|
||||
data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }),
|
||||
source_event_seqs: null, surface_op: null },
|
||||
]
|
||||
const { preserved } = scanRows(rows)
|
||||
@@ -847,7 +847,7 @@ describe('surface field round-trip', () => {
|
||||
}),
|
||||
}, { surfaceOp: 'append', sourceEventSeqs: [2] })
|
||||
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' } })
|
||||
await ctx.sessions.flush(session)
|
||||
const loaded = await ctx.sessionPersistence.load(SessionId('roundtrip-surface'))
|
||||
expect(loaded.events).toHaveLength(6)
|
||||
@@ -870,7 +870,7 @@ describe('surface field round-trip', () => {
|
||||
content: [],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.sessions.flush(session)
|
||||
const loaded = await ctx.sessionPersistence.load(SessionId('surface-noseq'))
|
||||
expect((loaded.events[1]! as SurfaceEvent).surfaceOp).toBe('append')
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/session-persistence/session-persistence/README.md
|
||||
README.md: 7554d8e6804f880712f38f44efc2ed2f60a7dbfc
|
||||
README.zh.md: 0368e9d60c0217e5f030f4cd7880583d0f67aa94
|
||||
README.md: 1de7b237d7ca812a55b080e8d91161551cea2129
|
||||
README.zh.md: 9f5281a07a0ba2fa7caa761a02f99fbfc4a20582
|
||||
|
||||
@@ -34,7 +34,7 @@ Each `session/event` copies its event into the session controller and starts an
|
||||
|
||||
Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative in-memory log, waits for that snapshot to become durable, and returns it with the coordinator's stored header only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. A cold load reserves its id across backend reads and repair writes, so concurrent publication of a same-id live `Session` rejects and rolls back. HMR adoption reads through `loadStored`, applies the coordinator's cwd check, and never closes the active turn.
|
||||
|
||||
Backend reads normalize the exact supported same-version shapes before current-shape validation. Pre-identity messages receive the deterministic id `legacy-message:<session-id>:<event-seq>`; a tool-result content replacement inherits its target's imported id. A pre-react-loop `turn/start` loses its obsolete trigger, a removed `steering/message` becomes the same identified `user/message`, and an older `turn/end` gains its last entered step while its terminal reason maps without inventing unavailable cancellation provenance. The coordinator uses the same normalized view for `load`, `inspect`, `readFrom`, ownerless-state claims, and HMR prefix adoption. Storage remains append-only: reads do not rewrite old records, and later appends use the current shape. These are narrow import exceptions from the [pre-identity message](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md) and [pre-react-loop session](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md) decisions, not a general v0 migration promise.
|
||||
Backend reads normalize the exact supported same-version shapes before current-shape validation. Pre-identity messages receive the deterministic id `legacy-message:<session-id>:<event-seq>`; a tool-result content replacement inherits its target's imported id. A pre-react-loop `turn/start` loses its obsolete trigger, a removed `steering/message` becomes the same identified `user/message`, and an older `turn/end` maps its terminal reason without inventing unavailable cancellation provenance. The coordinator uses the same normalized view for `load`, `inspect`, `readFrom`, ownerless-state claims, and HMR prefix adoption. Storage remains append-only: reads do not rewrite old records, and later appends use the current shape. These are narrow import exceptions from the [pre-identity message](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md) and [pre-react-loop session](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md) decisions, not a general v0 migration promise.
|
||||
|
||||
When a live session emits `session/disposed`, the coordinator waits for its controller, serializes a final drain, then releases state owned by that exact `Session` object. Failed retirement leaves the controller in the live-session map, so backend teardown can retry it. Backend teardown stops event admission first, flushes every remaining controller, awaits per-id operations, and only then closes the storage handle.
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
崩溃修复只适用于冷状态。对于实时 id,`load(id)` 为权威内存日志制作快照,等待该快照持久,并只在平衡时将其与协调器已存储 header 一起返回;开放实时轮次会被拒绝,而不会收到合成中断 closer。冷 load 在后端读取和修复写入期间保留 id,因此同 id 实时 `Session` 的并发发布会拒绝并回滚。HMR 接管通过 `loadStored` 读取,应用协调器 cwd 检查,并绝不关闭活动轮次。
|
||||
|
||||
后端读取会在当前形状验证前,规范化明确受支持的同版本形状。消息标识机制引入前的消息会获得确定性的 id `legacy-message:<session-id>:<event-seq>`;工具结果的内容替换会继承其目标导入后的 id。react-loop 重构前的 `turn/start` 会移除过时的 trigger,已移除的 steering(中途引导)事件 `steering/message` 会转换为同一条带标识的 `user/message`;旧版 `turn/end` 会补上最后进入的步骤,并在不虚构无法获得的取消来源的前提下映射终止原因。协调器对 `load`、`inspect`、`readFrom`、无 owner 状态的认领和 HMR 前缀接管使用同一份规范化视图。存储仍然仅追加:读取不会重写旧记录,此后追加的事件使用当前形状。这些是[消息标识机制引入前的消息](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)与 [react-loop 重构前会话](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md)决策所规定的范围受限的导入例外,并不构成通用的 v0 迁移承诺。
|
||||
后端读取会在当前形状验证前,规范化明确受支持的同版本形状。消息标识机制引入前的消息会获得确定性的 id `legacy-message:<session-id>:<event-seq>`;工具结果的内容替换会继承其目标导入后的 id。react-loop 重构前的 `turn/start` 会移除过时的 trigger,已移除的 steering(中途引导)事件 `steering/message` 会转换为同一条带标识的 `user/message`;旧版 `turn/end` 会在不虚构无法获得的取消来源的前提下映射终止原因。协调器对 `load`、`inspect`、`readFrom`、无 owner 状态的认领和 HMR 前缀接管使用同一份规范化视图。存储仍然仅追加:读取不会重写旧记录,此后追加的事件使用当前形状。这些是[消息标识机制引入前的消息](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)与 [react-loop 重构前会话](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md)决策所规定的范围受限的导入例外,并不构成通用的 v0 迁移承诺。
|
||||
|
||||
实时会话发出 `session/disposed` 时,协调器等待其 controller,串行化最终 drain,然后释放该精确 `Session` 对象拥有的状态。失败退役会将 controller 保留在实时会话 map 中,使后端拆卸可重试。后端拆卸先停止事件接纳,flush 每个剩余 controller,等待每 id 操作,最后才关闭存储句柄。
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ export function oneTurnLog(): SessionEvent[] {
|
||||
}),
|
||||
}, surfaceOp: 'append' },
|
||||
{ type: 'step/end', seq: 4, time: 5, data: { turn: 1, step: 1 } },
|
||||
{ type: 'turn/end', seq: 5, time: 6, data: { turn: 1, step: 1, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 5, time: 6, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
||||
// the balanced length (seq 10), and a reload round-trips identically.
|
||||
await persistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 10, time: 9, data: { turn: 3 } },
|
||||
{ type: 'turn/end', seq: 11, time: 10, data: { turn: 3, step: 0, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 11, time: 10, data: { turn: 3, reason: { kind: 'completed' } } },
|
||||
])
|
||||
const reloaded = await persistence.load(m.id)
|
||||
expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
|
||||
|
||||
@@ -104,7 +104,7 @@ function legacyMessageLog(): SessionEvent[] {
|
||||
surfaceOp: { op: 'replace', start: 5, end: 5 },
|
||||
},
|
||||
{ type: 'step/end', seq: 7, time: 9, data: { turn: 1, step: 1 } },
|
||||
{ type: 'turn/end', seq: 8, time: 10, data: { turn: 1, step: 1, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 8, time: 10, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
] as unknown as SessionEvent[]
|
||||
}
|
||||
|
||||
@@ -294,7 +294,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
const live = ctx.sessions.create(id, { seed: [start], meta: header })
|
||||
await expect(loading).rejects.toThrow(/live turn is open/)
|
||||
|
||||
live.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
live.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.sessions.flush(live)
|
||||
const loaded = await ctx.sessionPersistence.load(id)
|
||||
// The constructor's end-seed event persisted between the stored
|
||||
@@ -382,7 +382,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
expect(() => {
|
||||
;(ev.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED'
|
||||
}).toThrow(TypeError)
|
||||
session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.sessions.flush(session)
|
||||
|
||||
const loaded = await ctx.sessionPersistence.load(SessionId('mutate'))
|
||||
@@ -492,22 +492,19 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
{ turn: 1 }, { turn: 2 }, { turn: 3 }, { turn: 4 }, { turn: 5 }, { turn: 6 }, { turn: 7 },
|
||||
])
|
||||
expect(snapshot.events.filter(event => event.type === 'turn/end').map(event => event.data)).toEqual([
|
||||
{ turn: 1, step: 1, reason: { kind: 'completed' } },
|
||||
{ turn: 1, reason: { kind: 'completed' } },
|
||||
{
|
||||
turn: 2,
|
||||
step: 1,
|
||||
reason: { kind: 'error', error: { message: 'old provider failure', code: 'SERVER' } },
|
||||
},
|
||||
{ turn: 3, step: 0, reason: { kind: 'aborted', reason: { kind: 'legacy' } } },
|
||||
{ turn: 4, step: 0, reason: { kind: 'aborted', reason: { kind: 'disposed' } } },
|
||||
{ turn: 3, reason: { kind: 'aborted', reason: { kind: 'legacy' } } },
|
||||
{ turn: 4, reason: { kind: 'aborted', reason: { kind: 'disposed' } } },
|
||||
{
|
||||
turn: 5,
|
||||
step: 1,
|
||||
reason: { kind: 'error', error: { message: 'old thrown value', code: 'UNKNOWN' } },
|
||||
},
|
||||
{
|
||||
turn: 6,
|
||||
step: 0,
|
||||
reason: {
|
||||
kind: 'error',
|
||||
error: {
|
||||
@@ -521,7 +518,6 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
},
|
||||
{
|
||||
turn: 7,
|
||||
step: 0,
|
||||
reason: { kind: 'error', error: { message: 'old coded error', code: 'CODED' } },
|
||||
},
|
||||
])
|
||||
@@ -539,8 +535,8 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
seq: 3,
|
||||
data: { id: legacySteering.data.message.id },
|
||||
})
|
||||
expect(suffix.events.filter(event => event.type === 'turn/end').map(event => event.data.step))
|
||||
.toEqual([1, 1, 0, 0, 1, 0, 0])
|
||||
expect(suffix.events.filter(event => event.type === 'turn/end')
|
||||
.every(event => !Object.hasOwn(event.data, 'step'))).toBe(true)
|
||||
|
||||
const flatId = SessionId('pre-react-loop-flat-steering')
|
||||
await ctx.sessionPersistence.create(meta(flatId, WORK))
|
||||
@@ -633,6 +629,14 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
} as unknown as SessionEvent,
|
||||
message: 'malformed pre-react-loop turn/end',
|
||||
},
|
||||
{
|
||||
id: 'unsupported-intermediate-turn-end-step',
|
||||
event: {
|
||||
type: 'turn/end', seq: 0, time: 1,
|
||||
data: { turn: 1, step: 1, reason: { kind: 'completed' } },
|
||||
} as unknown as SessionEvent,
|
||||
message: 'malformed pre-react-loop turn/end',
|
||||
},
|
||||
{
|
||||
id: 'invalid-old-turn-end-aborted',
|
||||
event: {
|
||||
@@ -665,14 +669,6 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
} as unknown as SessionEvent,
|
||||
message: 'malformed pre-react-loop turn/end',
|
||||
},
|
||||
{
|
||||
id: 'invalid-old-turn-end-kind',
|
||||
event: {
|
||||
type: 'turn/end', seq: 0, time: 1,
|
||||
data: { turn: 1, reason: { kind: 'unknown' } },
|
||||
} as unknown as SessionEvent,
|
||||
message: 'malformed pre-react-loop turn/end',
|
||||
},
|
||||
]
|
||||
for (const malformed of malformedLegacy) {
|
||||
const malformedId = SessionId(malformed.id)
|
||||
@@ -798,7 +794,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
const s2 = second.ctx.sessions.create(SessionId('resumed'), { seed: loaded.events, meta: { cwd: WORK } })
|
||||
await second.ctx.sessions.flush(s2) // let onCreated adopt
|
||||
s2.append('turn/start', { turn: 2 })
|
||||
s2.append('turn/end', { turn: 2, step: 0, reason: { kind: 'completed' } })
|
||||
s2.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
await second.ctx.sessions.flush(s2)
|
||||
|
||||
const reloaded = await second.ctx.sessionPersistence.load(SessionId('resumed'))
|
||||
@@ -823,7 +819,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
const fiber = await fix.mount(ctx)
|
||||
try {
|
||||
@@ -847,7 +843,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'buffered' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
// No explicit flush — dispose must drain.
|
||||
await fiber.dispose()
|
||||
|
||||
@@ -875,7 +871,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.sessions.flush(session)
|
||||
|
||||
// Hot-reload: dispose instance 1, mount instance 2 over the same storage while the
|
||||
@@ -887,7 +883,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'again' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 2, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
await expect(ctx.sessions.flush(session)).resolves.not.toThrow()
|
||||
|
||||
const loaded = await ctx.sessionPersistence.load(SessionId('hmr-adopt'))
|
||||
@@ -907,7 +903,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
// Instance 1 flushes turn 1.
|
||||
const backend1 = await fix.mount(ctx)
|
||||
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' } })
|
||||
await ctx.sessions.flush(session)
|
||||
|
||||
// Append turn 2 to the LIVE session, then dispose instance 1 WITHOUT
|
||||
@@ -915,7 +911,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
// backend never buffered it via session/event.
|
||||
await backend1.dispose()
|
||||
session.append('turn/start', { turn: 2 })
|
||||
session.append('turn/end', { turn: 2, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
|
||||
// Instance 2 adopts the stored prefix (turn 1) and MUST also persist the
|
||||
// live suffix (turn 2) carried in the session's events.
|
||||
@@ -948,7 +944,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
// The live session is still the authority: it appends the REAL step/turn
|
||||
// end. Adoption must truncate the torn tail but NOT synthesize closers.
|
||||
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' } })
|
||||
await ctx.sessions.flush(session)
|
||||
|
||||
const loaded = await ctx.sessionPersistence.load(SessionId('hmr-open'))
|
||||
@@ -1008,7 +1004,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
}, { inject: ['sessions'] }))
|
||||
await expect(ctx.sessions.flush(reuse)).resolves.toBe(true)
|
||||
reuse.append('turn/start', { turn: 1 })
|
||||
reuse.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
reuse.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.sessions.flush(reuse)
|
||||
const loaded = await ctx.sessionPersistence.load(SessionId('abandoned'))
|
||||
expect(loaded.events.map(e => e.seq)).toEqual([0, 1])
|
||||
@@ -1029,7 +1025,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
await ctx.sessions.flush(first)
|
||||
// Append a turn but do NOT flush — events sit in the write-behind buffer.
|
||||
first.append('turn/start', { turn: 1 })
|
||||
first.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
first.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await firstFiber.dispose()
|
||||
|
||||
// Disposal is an observe-only notification. Poll storage rather than
|
||||
@@ -1059,7 +1055,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'x' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.sessions.flush(session)
|
||||
// Re-emit session/created for the SAME live session (idempotent initFor).
|
||||
ctx.emit(scopeTarget(session, undefined), 'session/created', session)
|
||||
@@ -1157,7 +1153,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
cont = inner.sessions.create(SessionId('claim'), { seed: [
|
||||
...events,
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
|
||||
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, step: 0, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
], meta: { cwd: WORK, createdAt: 2000 } })
|
||||
}, { inject: ['sessions'] }))
|
||||
await ctx.sessions.flush(cont)
|
||||
@@ -1249,7 +1245,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
try {
|
||||
await second.ctx.sessionPersistence.append(SessionId('adopt-append'), [
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
|
||||
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, step: 0, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
])
|
||||
const loaded = await second.ctx.sessionPersistence.load(SessionId('adopt-append'))
|
||||
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
|
||||
@@ -1352,7 +1348,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.sessions.flush(session)
|
||||
const loaded = await ctx.sessionPersistence.load(SessionId('flush-nostate'))
|
||||
expect(loaded.events).toHaveLength(3)
|
||||
@@ -1409,7 +1405,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
// (seq 10) and a reload round-trips identically.
|
||||
await second.ctx.sessionPersistence.append(SessionId('torn'), [
|
||||
{ type: 'turn/start', seq: 10, time: 9, data: { turn: 3 } },
|
||||
{ type: 'turn/end', seq: 11, time: 10, data: { turn: 3, step: 0, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 11, time: 10, data: { turn: 3, reason: { kind: 'completed' } } },
|
||||
])
|
||||
const reloaded = await second.ctx.sessionPersistence.load(SessionId('torn'))
|
||||
expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
|
||||
|
||||
@@ -240,7 +240,7 @@ describe('PersistenceCoordinator eager writes', () => {
|
||||
session.append('turn/start', { turn: 1 })
|
||||
await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) })
|
||||
|
||||
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
appendGate.resolve(true)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
@@ -273,7 +273,7 @@ describe('PersistenceCoordinator eager writes', () => {
|
||||
const session = ctx.sessions.create(SessionId('eager-flush-retry'))
|
||||
await ctx.sessions.flush(session)
|
||||
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' } })
|
||||
await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) })
|
||||
|
||||
const barriers = [ctx.sessions.flush(session), ctx.sessions.flush(session)]
|
||||
@@ -535,7 +535,7 @@ describe('PersistenceCoordinator observation cancellation', () => {
|
||||
session = inner.sessions.create(id)
|
||||
}, { inject: ['sessions'] }))
|
||||
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' } })
|
||||
// Dispose the session so retirement starts; its append is gated, so the
|
||||
// retirement promise stays pending in the coordinator.
|
||||
await sessionFiber.dispose()
|
||||
@@ -678,7 +678,7 @@ describe('PersistenceCoordinator retirement', () => {
|
||||
await ctx.sessions.flush(first)
|
||||
backend.beforeAppend = async () => { await appendGate.promise }
|
||||
first.append('turn/start', { turn: 1 })
|
||||
first.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
first.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) })
|
||||
await firstFiber.dispose()
|
||||
|
||||
@@ -718,7 +718,7 @@ describe('PersistenceCoordinator retirement', () => {
|
||||
await ctx.sessions.flush(first)
|
||||
backend.beforeAppend = async () => { await appendGate.promise }
|
||||
first.append('turn/start', { turn: 1 })
|
||||
first.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
first.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) })
|
||||
await firstFiber.dispose()
|
||||
const baselineLoads = backend.loadAttempts
|
||||
@@ -782,7 +782,7 @@ describe('PersistenceCoordinator retirement', () => {
|
||||
type: 'turn/end',
|
||||
seq: 1,
|
||||
time: 2,
|
||||
data: { turn: 1, step: 0, reason: { kind: 'completed' } },
|
||||
data: { turn: 1, reason: { kind: 'completed' } },
|
||||
}])
|
||||
|
||||
await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) })
|
||||
@@ -825,7 +825,7 @@ describe('PersistenceCoordinator retirement', () => {
|
||||
session = inner.sessions.create(SessionId('retry-retirement'))
|
||||
}, { inject: ['sessions'] }))
|
||||
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' } })
|
||||
await sessionFiber.dispose()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
@@ -869,7 +869,7 @@ describe('PersistenceCoordinator retirement', () => {
|
||||
session = inner.sessions.create(SessionId('inflight-retirement'))
|
||||
}, { inject: ['sessions'] }))
|
||||
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' } })
|
||||
await sessionFiber.dispose()
|
||||
await vi.waitFor(() => {
|
||||
expect(backend.appendAttempts).toBe(1)
|
||||
@@ -1054,7 +1054,7 @@ describe('SessionPersistence service registration', () => {
|
||||
session = inner.sessions.create(SessionId(`disposed-${index}`))
|
||||
}, { inject: ['sessions'] }))
|
||||
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' } })
|
||||
await ctx.sessions.flush(session)
|
||||
await sessionFiber.dispose()
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ const mark = (session: Session, marks: string[]): SessionEvent =>
|
||||
session.append('cache-test/mark', { marks })
|
||||
|
||||
const endTurn = (session: Session): SessionEvent =>
|
||||
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
/** The stored medium record for one session id (undefined = never written). */
|
||||
function storedRecord(pool: MemoryMediaPool, id: Session['id']) {
|
||||
@@ -226,7 +226,7 @@ describe('SessionProjectionCache cold read', () => {
|
||||
for (const m of marks) {
|
||||
events.push({ type: 'cache-test/mark', seq: events.length, time: events.length, data: { marks: m } })
|
||||
}
|
||||
events.push({ type: 'turn/end', seq: events.length, time: events.length, data: { turn: 1, step: 0, reason: { kind: 'completed' } } })
|
||||
events.push({ type: 'turn/end', seq: events.length, time: events.length, data: { turn: 1, reason: { kind: 'completed' } } })
|
||||
return events
|
||||
}
|
||||
|
||||
|
||||
@@ -225,7 +225,7 @@ describe('SessionProjectionRegistry drive', () => {
|
||||
ctx.sessionProjections.register(countUnit())
|
||||
const tail: SessionEvent[] = [
|
||||
{ type: 'test/mark', seq: 3, time: 3, data: { marks: ['new'] } },
|
||||
{ type: 'turn/end', seq: 4, time: 4, data: { turn: 1, step: 0, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 4, time: 4, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
// marks row usable (watermark 2, tail starts at 3); count row mismatched — but
|
||||
// a mismatch with baseSeq > 0 cannot silently refold: it throws for a re-read.
|
||||
@@ -262,7 +262,7 @@ describe('SessionProjectionRegistry drive', () => {
|
||||
}
|
||||
const tail: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 3, time: 3, data: { turn: 2 } },
|
||||
{ type: 'turn/end', seq: 4, time: 4, data: { turn: 2, step: 0, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 4, time: 4, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
]
|
||||
const { snapshot } = ctx.sessionProjections.restore(rows, tail, 3)
|
||||
expect(snapshot.asOfSeq).toBe(4)
|
||||
@@ -302,7 +302,7 @@ describe('SessionProjectionRegistry drive', () => {
|
||||
const floor = ctx.sessionProjections.restoreFloor(rows)
|
||||
expect(floor).toBe(9)
|
||||
// …an intact log serves the anchor event and the checkpoint stands as-is.
|
||||
const anchor: SessionEvent = { type: 'turn/end', seq: 9, time: 9, data: { turn: 2, step: 0, reason: { kind: 'completed' } } }
|
||||
const anchor: SessionEvent = { type: 'turn/end', seq: 9, time: 9, data: { turn: 2, reason: { kind: 'completed' } } }
|
||||
expect(ctx.sessionProjections.restore(rows, [anchor], 9).snapshot.values['test/count']).toBe(10)
|
||||
// …while a log crash-repaired down to fewer events returns an empty tail:
|
||||
// the row overreaches the proven end and a tail read cannot fix this key.
|
||||
@@ -310,7 +310,7 @@ describe('SessionProjectionRegistry drive', () => {
|
||||
// The full re-read discards the overreaching row and refolds from init.
|
||||
const events: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } },
|
||||
{ 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' } } },
|
||||
]
|
||||
const { snapshot } = ctx.sessionProjections.restore(rows, events, 0)
|
||||
expect(snapshot.asOfSeq).toBe(1)
|
||||
|
||||
@@ -340,7 +340,7 @@ describe('SQLite session search', () => {
|
||||
{ type: 'user/message', seq: 2, time: 12, data: createUserMessage({
|
||||
content: [{ type: 'text', text: 'needle summary' }], source: { kind: 'plugin', plugin: 'test' },
|
||||
}), surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] },
|
||||
{ type: 'turn/end', seq: 3, time: 13, data: { turn: 1, step: 1, reason: { kind: 'error', error: { message: 'needle failure', code: 'UNKNOWN' } } } },
|
||||
{ type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'error', error: { message: 'needle failure', code: 'UNKNOWN' } } } },
|
||||
]
|
||||
ctx.sessions.create(SessionId('a'), { seed: events, meta: { cwd: '/a', parentSession: parent, createdAt: 20 } })
|
||||
ctx.sessions.create(SessionId('b'), { seed: messageEvents('needle peer', 12), meta: { createdAt: 20 } })
|
||||
|
||||
@@ -129,7 +129,7 @@ describe('session-query semantic extraction', () => {
|
||||
[{ kind: 'future-status' } as never, ''],
|
||||
]
|
||||
for (const [reason, text] of reasons) {
|
||||
expect(extractSessionEventText({ type: 'turn/end', seq: 0, time: 1, data: { turn: 1, step: 1, reason } })).toBe(text)
|
||||
expect(extractSessionEventText({ type: 'turn/end', seq: 0, time: 1, data: { turn: 1, reason } })).toBe(text)
|
||||
}
|
||||
const structural: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
@@ -160,7 +160,7 @@ describe('session-query document and filter helpers', () => {
|
||||
},
|
||||
}),
|
||||
}, surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] },
|
||||
{ type: 'turn/end', seq: 3, time: 13, data: { turn: 1, step: 1, reason: { kind: 'interrupted' } } },
|
||||
{ type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'interrupted' } } },
|
||||
]
|
||||
|
||||
it('classifies every event and omits non-semantic documents', () => {
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('all-messages LLM title provider', () => {
|
||||
seeded.append('session/title', {
|
||||
title: 'Inherited fallback', messageSeqs: [inherited.seq], source: { kind: 'fallback' },
|
||||
})
|
||||
seeded.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
seeded.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
|
||||
@@ -89,7 +89,7 @@ function request(ctx: Context, signal = new AbortController().signal): SessionTi
|
||||
content: [{ type: 'text', text: '第二个问题' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
return {
|
||||
session,
|
||||
messages: [
|
||||
|
||||
@@ -30,7 +30,7 @@ async function appendPersistedTitle(ctx: Context, id: ReturnType<typeof SessionI
|
||||
content: [{ type: 'text', text: 'Persist this session title' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.sessionTitle.refresh(session)
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('SessionTitleService provider lifecycle', () => {
|
||||
})
|
||||
const inheritedMessage = appendHumanPrompt(parent, 'Inherited title prompt')
|
||||
await settle()
|
||||
parent.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
parent.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
const child = ctx.sessions.fork(parent, undefined, SessionId('title-child'))
|
||||
expect(ctx.sessionTitle.get(child)).toEqual(ctx.sessionTitle.get(parent))
|
||||
@@ -81,7 +81,7 @@ describe('SessionTitleService provider lifecycle', () => {
|
||||
await settle()
|
||||
appendRoute(child)
|
||||
await settle()
|
||||
child.append('turn/end', { turn: 2, step: 0, reason: { kind: 'completed' } })
|
||||
child.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
expect(firstGenerate).not.toHaveBeenCalled()
|
||||
await disposeFirst()
|
||||
|
||||
@@ -101,7 +101,7 @@ describe('SessionTitleService provider lifecycle', () => {
|
||||
await settle()
|
||||
appendRoute(child, 'change')
|
||||
await settle()
|
||||
child.append('turn/end', { turn: 3, step: 0, reason: { kind: 'completed' } })
|
||||
child.append('turn/end', { turn: 3, reason: { kind: 'completed' } })
|
||||
|
||||
expect(allGenerate).toHaveBeenCalledOnce()
|
||||
expect(ctx.sessionTitle.get(child)).toMatchObject({
|
||||
@@ -287,7 +287,7 @@ describe('SessionTitleService provider lifecycle', () => {
|
||||
appendRoute(session)
|
||||
await settle()
|
||||
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,
|
||||
|
||||
@@ -168,7 +168,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => {
|
||||
turn: 1,
|
||||
})
|
||||
const source = appendPrompt(seed, 'Create exactly one fallback title')
|
||||
seed.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
seed.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
const session = ctx.sessions.create(SessionId('fallback-concurrency'), { seed: seed.events })
|
||||
|
||||
const results = await Promise.all([
|
||||
@@ -210,7 +210,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => {
|
||||
const session = startSession(ctx, 'refresh-order')
|
||||
const source = appendPrompt(session, 'Keep the newest explicit refresh')
|
||||
await settle()
|
||||
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
const requests: SessionTitleProviderRequest[] = []
|
||||
const results: Array<ReturnType<typeof deferred<SessionTitleProviderResult>>> = []
|
||||
ctx.sessionTitle.register({
|
||||
|
||||
@@ -573,7 +573,7 @@ describe('runScenario', () => {
|
||||
file: 'project/main/session.jsonl',
|
||||
lines: [
|
||||
{ type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
|
||||
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, step: 0, reason: { kind: 'aborted' } } },
|
||||
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'aborted' } } },
|
||||
],
|
||||
}],
|
||||
})
|
||||
@@ -641,7 +641,7 @@ describe('runScenario', () => {
|
||||
file: 'project/main/session.jsonl',
|
||||
lines: [
|
||||
{ type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
|
||||
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, step: 0, reason: { kind: 'aborted' } } },
|
||||
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'aborted' } } },
|
||||
{ type: 'session/title', seq: 2, time: 3, data: { title: 'Late title' } },
|
||||
],
|
||||
}],
|
||||
@@ -721,7 +721,7 @@ describe('runScenario', () => {
|
||||
lines: [
|
||||
{ type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, step: 0, reason: { kind: 'stop' } } },
|
||||
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'stop' } } },
|
||||
],
|
||||
}],
|
||||
})
|
||||
@@ -919,7 +919,7 @@ describe('runScenario', () => {
|
||||
lines: [
|
||||
{ type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
|
||||
{ type: 'session/title', seq: 1, time: 1, data: { title: 'Early title' } },
|
||||
{ type: 'turn/end', seq: 2, time: 2, data: { turn: 1, step: 0, reason: { kind: 'aborted' } } },
|
||||
{ type: 'turn/end', seq: 2, time: 2, data: { turn: 1, reason: { kind: 'aborted' } } },
|
||||
],
|
||||
}],
|
||||
})
|
||||
|
||||
@@ -160,7 +160,7 @@ describe('deriveReplayScript', () => {
|
||||
const events: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: seq++, time: 0, data: { turn: 1 } },
|
||||
...TEXT_CHUNKS.map(c => chunkEvent(seq++, 1, 1, c)),
|
||||
{ type: 'turn/end', seq: seq++, time: 0, data: { turn: 1, step: 1, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: seq++, time: 0, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
expect(deriveReplayScript(events)).toEqual([{ kind: 'chunks', chunks: TEXT_CHUNKS }])
|
||||
})
|
||||
@@ -183,7 +183,7 @@ describe('deriveReplayScript', () => {
|
||||
const events: SessionEvent[] = [
|
||||
chunkEvent(1, 1, 1, { type: 'block-start', index: 0, blockType: 'text' }),
|
||||
chunkEvent(2, 1, 1, { type: 'text-delta', index: 0, text: 'par' }),
|
||||
{ type: 'turn/end', seq: 3, time: 0, data: { turn: 1, step: 1, reason: { kind: 'error', error: { message: 'x', code: 'UNKNOWN' } } } },
|
||||
{ type: 'turn/end', seq: 3, time: 0, data: { turn: 1, reason: { kind: 'error', error: { message: 'x', code: 'UNKNOWN' } } } },
|
||||
]
|
||||
expect(() => deriveReplayScript(events)).toThrow(/without a finish chunk.*replay\.override\.json/s)
|
||||
})
|
||||
|
||||
@@ -113,7 +113,7 @@ describe('TelemetryOtel wire', () => {
|
||||
const { ctx, fiber } = await boot(url)
|
||||
const session = ctx.sessions.create(SessionId('wire'), { meta: { cwd: '/tmp/w' } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'error', error: { message: 'boom', code: 'UNKNOWN' } } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'error', error: { message: 'boom', code: 'UNKNOWN' } } })
|
||||
await fiber.dispose()
|
||||
|
||||
expect(captures.length).toBeGreaterThan(0)
|
||||
|
||||
@@ -126,7 +126,7 @@ describe('TelemetryCoordinator capture', () => {
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('telemetry-test/opaque', { payload: { nested: [] } })
|
||||
session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'error', error: { message: 'boom', code: 'UNKNOWN' } } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'error', error: { message: 'boom', code: 'UNKNOWN' } } })
|
||||
const severities = backend.ledger().map(r => [r.attributes['event.type'], r.severity])
|
||||
expect(severities).toEqual([
|
||||
['turn/start', 'info'],
|
||||
@@ -180,7 +180,7 @@ describe('TelemetryCoordinator adoption', () => {
|
||||
apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
|
||||
})
|
||||
const child = ctx.sessions.prepare(SessionId('seeded'), { seed: [...parent.events], meta: {} })
|
||||
child.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
child.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
ctx.sessions.enter(child)
|
||||
ctx.sessions.announce(child)
|
||||
|
||||
@@ -234,7 +234,7 @@ describe('TelemetryCoordinator adoption', () => {
|
||||
inject: ['sessions'],
|
||||
apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
|
||||
})
|
||||
child.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
child.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
const record = backend.ledger().find(r => r.attributes['session.id'] === 'stitch-child')!
|
||||
expect(record.attributes['session.parent_id']).toBe('stitch-parent')
|
||||
expect(record.attributes['session.seed_length']).toBe(2)
|
||||
@@ -272,7 +272,7 @@ describe('TelemetryCoordinator adoption', () => {
|
||||
await fiber.dispose()
|
||||
// The reload window: appends while no telemetry listener is registered.
|
||||
session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'mid-step continuation' } })
|
||||
session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
const second = new FakeBackend()
|
||||
await ctx.plugin({
|
||||
@@ -292,7 +292,7 @@ describe('TelemetryCoordinator adoption', () => {
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const session = liveSession(ctx, 'partial')
|
||||
appendTurn(session)
|
||||
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
// The backend rejects exactly the middle historical event: fail-closed
|
||||
// must withhold THAT record only — an adoption replay that dies on the
|
||||
// first contained failure would silently skip the rest of the log while
|
||||
@@ -415,7 +415,7 @@ describe('TelemetryCoordinator lifecycle and containment', () => {
|
||||
expect(() => session.append('turn/start', { turn: 1 })).not.toThrow()
|
||||
expect(warn).toHaveBeenCalled()
|
||||
backend.emitError = undefined
|
||||
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
expect(backend.ledger().map(r => r.attributes['event.type'])).toEqual(['turn/end'])
|
||||
})
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ describe('todos projection provider', () => {
|
||||
seedMessage(session)
|
||||
const list: TodoItem[] = [{ content: 'done', status: 'completed' }]
|
||||
session.append('todo/write', { todos: list })
|
||||
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
expect((await bench.tailProjections())?.values.todos).toEqual(list)
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const cleared = await bench.tailProjections()
|
||||
|
||||
@@ -221,7 +221,7 @@ describe('new-session default', () => {
|
||||
})
|
||||
const legacy = freshSession('legacy-source')
|
||||
legacy.append('turn/start', { turn: 1 })
|
||||
legacy.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
legacy.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
const resumed = ctx.sessions.create(SessionId('legacy-resumed'), { seed: legacy.events })
|
||||
expect(ctx.permission.current(resumed.events)).toBe('workspace-write')
|
||||
expect(resumed.events.slice(-3).map(event => event.type)).toEqual([
|
||||
|
||||
@@ -407,7 +407,7 @@ describe('TUI terminal-state snapshots', () => {
|
||||
],
|
||||
})
|
||||
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,
|
||||
})
|
||||
@@ -454,7 +454,7 @@ describe('TUI terminal-state snapshots', () => {
|
||||
},
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
harness.session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
|
||||
harness.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await checkpoint('retry-recovered', harness.terminal, { includeScrollback: true })
|
||||
await disposeSnapshot(harness)
|
||||
})
|
||||
@@ -473,7 +473,7 @@ describe('TUI terminal-state snapshots', () => {
|
||||
delayMs: 1_000,
|
||||
failure: { message: 'temporary transport failure', code: 'TRANSPORT' },
|
||||
})
|
||||
harness.session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'aborted', reason: { kind: 'user' } },
|
||||
harness.session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } },
|
||||
})
|
||||
})
|
||||
await checkpoint('retry-cancelled', harness.terminal, { includeScrollback: true })
|
||||
@@ -489,7 +489,7 @@ describe('TUI terminal-state snapshots', () => {
|
||||
step: 3,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'discarded terminal partial output' },
|
||||
})
|
||||
harness.session.append('turn/end', { turn: 1, step: 3, reason: { kind: 'error', error: { message: 'provider still unavailable', code: 'SERVER', status: 503 },
|
||||
harness.session.append('turn/end', { turn: 1, reason: { kind: 'error', error: { message: 'provider still unavailable', code: 'SERVER', status: 503 },
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -645,7 +645,7 @@ describe('TUI terminal-state snapshots', () => {
|
||||
harness.session.append('step/start', { turn: 1, step: 2 })
|
||||
appendAssistant(harness.session, [{ type: 'text', text: 'The renderer is sound; no refactor needed.' }], undefined, { turn: 1, step: 2 })
|
||||
harness.session.append('step/end', { turn: 1, step: 2 })
|
||||
harness.session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
|
||||
harness.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
})
|
||||
// collapsed -> expanded -> hidden: one Assistant header, no tool card.
|
||||
await renderAfter(harness, () => { harness.terminal.send('\x0f') })
|
||||
@@ -672,7 +672,7 @@ describe('TUI terminal-state snapshots', () => {
|
||||
])
|
||||
appendToolResult(harness.session, 'details-1', [{ type: 'text', text: 'all tests pass' }])
|
||||
harness.session.append('step/end', { turn: 1, step: 1 })
|
||||
harness.session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
|
||||
harness.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
})
|
||||
await renderAfter(harness, () => {
|
||||
harness.terminal.send('/details hidden reasoning off')
|
||||
@@ -733,7 +733,7 @@ describe('TUI terminal-state snapshots', () => {
|
||||
todos: [{ content: `Unsafe todo ${CONTROL_PROBE}`, status: 'in_progress' }],
|
||||
})
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'error', error: { message: `Unsafe turn error ${CONTROL_PROBE}`, code: 'UNKNOWN' } },
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'error', error: { message: `Unsafe turn error ${CONTROL_PROBE}`, code: 'UNKNOWN' } },
|
||||
})
|
||||
},
|
||||
}, { columns: 100, rows: 34 })
|
||||
@@ -905,18 +905,18 @@ describe('TUI terminal-state snapshots', () => {
|
||||
harness.terminal.send('\r')
|
||||
agentEvents(harness.ctx, harness.agent).emit('agent/error', 1, 1, new Error('provider stream failed after partial output'))
|
||||
harness.session.append('step/end', { turn: 1, step: 1 })
|
||||
harness.session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'error', error: { message: 'provider stream failed after partial output', code: 'UNKNOWN' } },
|
||||
harness.session.append('turn/end', { turn: 1, reason: { kind: 'error', error: { message: 'provider stream failed after partial output', code: 'UNKNOWN' } },
|
||||
})
|
||||
harness.session.append('turn/start', { turn: 2 })
|
||||
harness.session.append('turn/end', { turn: 2, step: 0, reason: { kind: 'interrupted' },
|
||||
harness.session.append('turn/end', { turn: 2, reason: { kind: 'interrupted' },
|
||||
})
|
||||
harness.session.append('turn/start', { turn: 3 })
|
||||
harness.session.append('turn/end', { turn: 3, step: 0, reason: { kind: 'aborted', reason: { kind: 'disposed' } },
|
||||
harness.session.append('turn/end', { turn: 3, reason: { kind: 'aborted', reason: { kind: 'disposed' } },
|
||||
})
|
||||
harness.session.append('turn/start', { turn: 4 })
|
||||
// A merge-extensible turn-end kind unknown to the TUI still surfaces its
|
||||
// name so the agent never stops without a visible reason.
|
||||
harness.session.append('turn/end', { turn: 4, step: 0, reason: { kind: 'plugin-policy' } as never })
|
||||
harness.session.append('turn/end', { turn: 4, reason: { kind: 'plugin-policy' } as never })
|
||||
})
|
||||
await checkpoint('errors-and-help', harness.terminal, { includeScrollback: true })
|
||||
|
||||
@@ -967,7 +967,7 @@ describe('TUI terminal-state snapshots', () => {
|
||||
}),
|
||||
}, surfaceOp: 'append' },
|
||||
{ type: 'step/end', seq: 5, time: Date.parse(`${day}T00:00:06Z`), data: { turn: 1, step: 1 } },
|
||||
{ type: 'turn/end', seq: 6, time: Date.parse(`${day}T00:00:07Z`), data: { turn: 1, step: 1, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/end', seq: 6, time: Date.parse(`${day}T00:00:07Z`), data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
{ type: 'session/title', seq: 7, time: Date.parse(`${day}T00:00:08Z`), data: { title, messageSeqs: [1], source: { kind: 'fallback' } } },
|
||||
],
|
||||
})
|
||||
|
||||
@@ -281,7 +281,7 @@ describe('goodbye message and /resume', () => {
|
||||
}),
|
||||
}, surfaceOp: 'append' },
|
||||
{ type: 'step/end', seq: 5, time: time + 5, data: { turn: 1, step: 1 } },
|
||||
{ type: 'turn/end', seq: 6, time: time + 6, data: { turn: 1, step: 1, reason } },
|
||||
{ type: 'turn/end', seq: 6, time: time + 6, data: { turn: 1, reason } },
|
||||
{ type: 'session/title', seq: 7, time: time + 7, data: { title, messageSeqs: [1], source: { kind: 'fallback' } } },
|
||||
]
|
||||
/** Derive the selector's batch title read from a fake per-session readSession. */
|
||||
@@ -1681,10 +1681,10 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
}), { surfaceOp: 'append' })
|
||||
appendAssistant(result.session, [])
|
||||
result.session.append('step/end', { turn: 1, step: 1 })
|
||||
result.session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'aborted', reason: { kind: 'user' } },
|
||||
result.session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } },
|
||||
})
|
||||
result.session.append('turn/start', { turn: 2 })
|
||||
result.session.append('turn/end', { turn: 2, step: 0, reason: { kind: 'completed' } })
|
||||
result.session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
result.session.append('turn/start', { turn: 3 })
|
||||
result.session.append('step/start', { turn: 3, step: 1 })
|
||||
result.session.append('assistant/chunk', {
|
||||
@@ -2172,7 +2172,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'done' } })
|
||||
clock += 1_000
|
||||
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' } })
|
||||
},
|
||||
})
|
||||
result.agent.status = 'running'
|
||||
@@ -4384,25 +4384,25 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
agentEvents(events.ctx, unrelatedAgent).emit('agent/disposed')
|
||||
agentEvents(events.ctx, events.agent).emit('agent/error', 1, 1, new Error('live failure'))
|
||||
events.session.append('step/end', { turn: 1, step: 1 })
|
||||
events.session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'error', error: { message: 'live failure', code: 'UNKNOWN' } } })
|
||||
events.session.append('turn/end', { turn: 1, reason: { kind: 'error', error: { message: 'live failure', code: 'UNKNOWN' } } })
|
||||
events.session.append('turn/start', { turn: 2 })
|
||||
events.session.append('turn/end', { turn: 2, step: 0, reason: { kind: 'error', error: { message: 'durable failure', code: 'UNKNOWN' } } })
|
||||
events.session.append('turn/end', { turn: 2, reason: { kind: 'error', error: { message: 'durable failure', code: 'UNKNOWN' } } })
|
||||
events.session.append('turn/start', { turn: 3 })
|
||||
events.session.append('turn/end', { turn: 3, step: 0, reason: { kind: 'aborted', reason: { kind: 'user' } },
|
||||
events.session.append('turn/end', { turn: 3, reason: { kind: 'aborted', reason: { kind: 'user' } },
|
||||
})
|
||||
events.session.append('turn/start', { turn: 4 })
|
||||
events.session.append('turn/end', { turn: 4, step: 0, reason: { kind: 'max-tokens' } })
|
||||
events.session.append('turn/end', { turn: 4, reason: { kind: 'max-tokens' } })
|
||||
events.session.append('turn/start', { turn: 5 })
|
||||
events.session.append('turn/end', { turn: 5, step: 0, reason: { kind: 'interrupted' } })
|
||||
events.session.append('turn/end', { turn: 5, reason: { kind: 'interrupted' } })
|
||||
events.session.append('turn/start', { turn: 6 })
|
||||
events.session.append('turn/end', { turn: 6, step: 0, reason: { kind: 'error', error: { message: 'structured provider failure', code: 'SERVER' } },
|
||||
events.session.append('turn/end', { turn: 6, reason: { kind: 'error', error: { message: 'structured provider failure', code: 'SERVER' } },
|
||||
})
|
||||
events.session.append('turn/start', { turn: 8 })
|
||||
events.session.append('turn/end', { turn: 8, step: 0, reason: { kind: 'aborted', reason: { kind: 'disposed' } },
|
||||
events.session.append('turn/end', { turn: 8, reason: { kind: 'aborted', reason: { kind: 'disposed' } },
|
||||
})
|
||||
events.session.append('turn/start', { turn: 9 })
|
||||
// Merge-extensible reason kind unknown to the TUI still names the stop.
|
||||
events.session.append('turn/end', { turn: 9, step: 0, reason: { kind: 'plugin-policy' } as never })
|
||||
events.session.append('turn/end', { turn: 9, reason: { kind: 'plugin-policy' } as never })
|
||||
agentEvents(events.ctx, events.agent).emit('agent/disposed')
|
||||
await tick()
|
||||
expect(events.terminal.output).toContain('live failure')
|
||||
@@ -5612,7 +5612,7 @@ describe('tool cards and surface replay', () => {
|
||||
session.append('step/start', { turn: 1, step: 2 })
|
||||
appendAssistant(session, [{ type: 'text', text: 'second step text' }], undefined, { turn: 1, step: 2 })
|
||||
session.append('step/end', { turn: 1, step: 2 })
|
||||
session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
it('folds a turn to one Assistant header in hidden mode and restores headers on cycle', async () => {
|
||||
@@ -5664,14 +5664,14 @@ describe('tool cards and surface replay', () => {
|
||||
result.session.append('step/start', { turn: 1, step: 2 })
|
||||
appendAssistant(result.session, [{ type: 'text', text: 'late turn-one text' }], undefined, { turn: 1, step: 2 })
|
||||
result.session.append('step/end', { turn: 1, step: 2 })
|
||||
result.session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
|
||||
result.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
// Turn 2 keeps its own header.
|
||||
result.session.append('turn/start', { turn: 2 })
|
||||
appendUser(result.session, 'next turn')
|
||||
result.session.append('step/start', { turn: 2, step: 1 })
|
||||
appendAssistant(result.session, [{ type: 'text', text: 'turn-two text' }], undefined, { turn: 2, step: 1 })
|
||||
result.session.append('step/end', { turn: 2, step: 1 })
|
||||
result.session.append('turn/end', { turn: 2, step: 1, reason: { kind: 'completed' } })
|
||||
result.session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
await tick()
|
||||
|
||||
result.terminal.send('\x0f')
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('approval invariants', () => {
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(ApprovalInvariant)
|
||||
expect(() => session.append('approval/decided', { id, outcome: 'cancelled' })).not.toThrow()
|
||||
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
})
|
||||
|
||||
it('adopts a bare session first observed through publication', async () => {
|
||||
@@ -77,7 +77,7 @@ describe('approval invariants', () => {
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create()
|
||||
startTurn(session)
|
||||
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
session.append('approval/asked', {
|
||||
id: ApprovalRequestId('ask-replay'), toolName: 'bash',
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user