Merge remote-tracking branch 'origin/master' into jsonl-packed-chunk-rows

Master removed the stdio demo (#702c8cc30) — accept the deletion; this
branch's packChunks passthrough survives in acp-demo (auto-merged), and
cli-demo/tui-demo arrived from master without one (the follow-up snapshot
PR decides which demos expose the switch). Generated catalogs regenerated
over merged sources; the hand-written session.md durability paragraph
re-weaves this branch's lossless-encoding wording with master's invariant-
companion sentence.
This commit is contained in:
kingwl
2026-07-22 15:46:12 +08:00
1242 changed files with 57560 additions and 15291 deletions

View File

@@ -102,7 +102,7 @@ describe('SessionStore.fork', () => {
const { ctx, sessions } = await setup()
const reasons: TurnEndReason[] = [
{ kind: 'completed' },
{ kind: 'aborted', reason: 'cancelled by user' },
{ kind: 'aborted' },
{ kind: 'error', step: 1, message: 'model failed', code: 'MODEL' },
{ kind: 'disposed' },
{ kind: 'max-tokens' },

View File

@@ -0,0 +1,344 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { createScope, scopeTarget } from '@deepseek-ai/dsh-scope'
import { CallId } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants'
async function setup(): Promise<{ ctx: Context; fiber: Awaited<ReturnType<Context['plugin']>> }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(InvariantService)
const fiber = await ctx.plugin(SessionInvariant)
return { ctx, fiber }
}
describe('session-log invariants', () => {
it('keeps registration global when the companion is mounted under a scope', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(InvariantService)
let scopedCtx!: Context
await ctx.plugin(Object.assign((inner: Context) => {
scopedCtx = createScope(inner, {}).ctx
}, { inject: ['sessions', 'invariants'] }))
await scopedCtx.plugin(SessionInvariant)
const session = ctx.sessions.create(SessionId('global-under-scoped-invariants'))
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}).not.toThrow()
})
it('accepts a well-formed turn, step, and tool sequence', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } })
session.append('assistant/message', {
provenance: { provider: 'mock', model: 'mock' },
turn: 1,
step: 1,
content: [{ type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' }],
}, { surfaceOp: 'append' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false }, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}).not.toThrow()
})
it('does not advance committed trace state when a later dispatch listener vetoes', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create(SessionId('dispatch-veto-rollback'))
let veto = true
ctx.on('internal/dispatch', (_mode, name) => {
if (name !== 'session/event' || !veto) return
veto = false
throw new Error('later dispatch veto')
})
expect(() => session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})).toThrow('later dispatch veto')
expect(session.events).toEqual([])
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}).not.toThrow()
})
it('applies the committed transition after another postcommit observer throws', async () => {
const { ctx } = await setup()
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
const session = ctx.sessions.create(SessionId('postcommit-peer'))
ctx.on('session/event', () => { throw new Error('hostile observer') }, { prepend: true })
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}).not.toThrow()
expect(warnings).toHaveLength(2)
})
it('rejects non-monotonic event sequence numbers', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
ctx.emit(scopeTarget(session, undefined), 'session/event', session, {
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
} as never)
expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, {
type: 'turn/end',
seq: 0,
time: 2,
data: { turn: 1, reason: { kind: 'completed' } },
} as never) }).toThrow(/seq must strictly increase/)
})
it('enforces turn numbering and enclosure', async () => {
const first = await setup()
const open = first.ctx.sessions.create()
open.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => open.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
.toThrow(/turn 1 is still open/)
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, trigger: { kind: 'message', source: { kind: 'user' } } })
second.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(() => second.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }))
.toThrow(/expected turn 2, got 3/)
const outside = (await setup()).ctx.sessions.create()
expect(() => outside.append('user/message', {
content: [{ type: 'text', text: 'hi' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })).toThrow(/outside any open turn/)
expect(() => outside.append('steering/message', {
turn: 1,
content: [{ type: 'text', text: 'go' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })).toThrow(/outside any open turn/)
// Merge-extensible session events use the same default enclosure branch.
const appendUnknown = outside.append.bind(outside) as (type: string, data: unknown) => unknown
expect(() => { appendUnknown('plugin/marker', {}) }).toThrow(/outside any open turn/)
})
it('enforces open-step identity and numbering', async () => {
const wrongTurn = (await setup()).ctx.sessions.create()
wrongTurn.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => wrongTurn.append('step/start', { turn: 2, step: 1 })).toThrow(/open turn is 1/)
const nested = (await setup()).ctx.sessions.create()
nested.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
nested.append('step/start', { turn: 1, step: 1 })
expect(() => nested.append('step/start', { turn: 1, step: 2 })).toThrow(/while step 1 is still open/)
expect(() => nested.append('turn/end', { turn: 1, reason: { kind: 'completed' } }))
.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', {
provenance: { provider: 'mock', model: 'mock' },
turn: 1,
step: 2,
content: [],
}, { surfaceOp: 'append' })).toThrow(/open is turn 1\/step 1/)
const skipped = (await setup()).ctx.sessions.create()
skipped.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
skipped.append('step/start', { turn: 1, step: 1 })
skipped.append('step/end', { turn: 1, step: 1 })
expect(() => skipped.append('step/start', { turn: 1, step: 3 }))
.toThrow(/expected step 2 in turn 1, got 3/)
})
it('requires step-scoped stream and tool events to name the open step', async () => {
const chunk = (await setup()).ctx.sessions.create()
chunk.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => chunk.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'x' },
})).toThrow(/open is turn 1\/step null/)
const tool = (await setup()).ctx.sessions.create()
tool.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
tool.append('step/start', { turn: 1, step: 1 })
expect(() => tool.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('ghost'),
content: [],
isError: false,
}, { surfaceOp: 'append' })).toThrow(/no prior tool\/call/)
})
it('keeps fresh tool-result appends open-step checked', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => session.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('closed'),
content: [],
isError: false,
}, { surfaceOp: 'append' })).toThrow(/open is turn 1\/step null/)
})
it('treats a validated tool-result replacement as a turn-enclosed rewrite', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('tool/call', {
turn: 1,
step: 1,
callId: CallId('rewrite'),
name: 'echo',
arguments: '{}',
})
const original = session.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('rewrite'),
content: [{ type: 'text', text: 'original' }],
isError: false,
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => session.append('tool/result', {
...original.data,
content: [{ type: 'text', text: 'pruned' }],
}, {
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
sourceEventSeqs: [original.seq],
})).not.toThrow()
})
it('rejects a tool-result replacement outside a turn', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('tool/call', {
turn: 1,
step: 1,
callId: CallId('rewrite'),
name: 'echo',
arguments: '{}',
})
const original = session.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('rewrite'),
content: [{ type: 'text', text: 'original' }],
isError: false,
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(() => session.append('tool/result', {
...original.data,
content: [{ type: 'text', text: 'pruned' }],
}, {
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
sourceEventSeqs: [original.seq],
})).toThrow(/outside any open turn/)
})
it('allows interrupted repair results and unresolved calls at step end', async () => {
const repaired = (await setup()).ctx.sessions.create()
expect(() => {
repaired.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
repaired.append('step/start', { turn: 1, step: 1 })
repaired.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('crashed'),
content: [],
isError: true,
error: { name: 'InterruptedError', code: 'interrupted' },
}, { surfaceOp: 'append' })
repaired.append('step/end', { turn: 1, step: 1 })
repaired.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
}).not.toThrow()
const unresolved = (await setup()).ctx.sessions.create()
expect(() => {
unresolved.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
unresolved.append('step/start', { turn: 1, step: 1 })
unresolved.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
unresolved.append('step/end', { turn: 1, step: 1 })
unresolved.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } })
}).not.toThrow()
})
it('does not let a result in a later step satisfy an earlier call', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
session.append('step/end', { turn: 1, step: 1 })
session.append('step/start', { turn: 1, step: 2 })
expect(() => session.append('tool/result', {
turn: 1,
step: 2,
callId: CallId('c1'),
content: [],
isError: false,
}, { surfaceOp: 'append' })).toThrow(/no prior tool\/call in this step/)
})
it('replays seeded sessions and tracks each session independently', async () => {
const { ctx } = await setup()
const badSeed = [
{ type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
{ type: 'turn/start' as const, seq: 1, time: 0, data: { turn: 2, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
]
expect(() => ctx.sessions.create(undefined, { seed: badSeed })).toThrow(InvariantError)
const a = ctx.sessions.create(SessionId('a'))
const b = ctx.sessions.create(SessionId('b'))
a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => b.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }))
.not.toThrow()
})
it('rebuilds trace state for sessions that exist when the companion reloads', async () => {
const { ctx, fiber } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
await fiber.dispose()
await ctx.plugin(SessionInvariant)
expect(() => session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'h' },
})).not.toThrow()
expect(() => session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
.toThrow(/turn 1 is still open/)
})
it('removes all listeners when the companion is disposed', async () => {
const { ctx, fiber } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
await fiber.dispose()
expect(() => session.append('turn/start', {
turn: 2,
trigger: { kind: 'message', source: { kind: 'user' } },
})).not.toThrow()
})
})

View File

@@ -0,0 +1,226 @@
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
'test/log-only': { value: string }
}
interface OutOfBandSessionEventMap {
'test/log-only': true
}
}
const updateTrigger = { kind: 'injection', source: { kind: 'plugin', plugin: 'test' } } as const
describe('SessionStore.appendOutOfBand', () => {
it('joins an open turn without adding a boundary or flushing it', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('open'))
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const event = await ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'inside' },
updateTrigger,
)
expect(event).toMatchObject({ type: 'test/log-only', seq: 1, data: { value: 'inside' } })
expect(session.events.map(item => item.type)).toEqual(['turn/start', 'test/log-only'])
expect(flushes).toBe(0)
})
it('wraps a closed log in one zero-step turn and flushes the balanced update', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('closed'))
const flushedTypes: string[][] = []
ctx.on('session/flush', (flushed) => {
flushedTypes.push(flushed.events.map(event => event.type))
})
const first = await ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'first' },
updateTrigger,
)
const second = await ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'second' },
updateTrigger,
)
expect(first.seq).toBe(1)
expect(second.seq).toBe(4)
expect(session.events).toMatchObject([
{ type: 'turn/start', seq: 0, data: { turn: 1, trigger: updateTrigger } },
{ type: 'test/log-only', seq: 1, data: { value: 'first' } },
{ type: 'turn/end', seq: 2, data: { turn: 1, reason: { kind: 'completed' } } },
{ type: 'turn/start', seq: 3, data: { turn: 2, trigger: updateTrigger } },
{ type: 'test/log-only', seq: 4, data: { value: 'second' } },
{ type: 'turn/end', seq: 5, data: { turn: 2, reason: { kind: 'completed' } } },
])
expect(flushedTypes).toEqual([
['turn/start', 'test/log-only', 'turn/end'],
['turn/start', 'test/log-only', 'turn/end', 'turn/start', 'test/log-only', 'turn/end'],
])
})
it('closes and flushes a zero-step turn when the target event is rejected', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('rejected'))
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
await expect(ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 1n } as never,
updateTrigger,
)).rejects.toThrow(/non-JSON-serializable/)
expect(session.events).toMatchObject([
{ type: 'turn/start', data: { turn: 1 } },
{ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } },
])
expect(flushes).toBe(1)
})
it('does not flush when the synthetic turn cannot open', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('start-failure'))
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
await expect(ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'unreachable' },
{ ...updateTrigger, invalid: 1n } as never,
)).rejects.toThrow(/non-JSON-serializable/)
expect(session.events).toEqual([])
expect(flushes).toBe(0)
})
it('preserves a target rejection when the balancing flush also rejects', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('target-and-flush-failure'))
ctx.on('session/flush', () => { throw new Error('disk failed') })
await expect(ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 1n } as never,
updateTrigger,
)).rejects.toThrow(/non-JSON-serializable/)
expect(session.events.map(event => event.type)).toEqual([
'turn/start',
'turn/end',
])
})
it('keeps the session attached through publication and its flush', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.prepare(SessionId('dispose'))
const detach = ctx.sessions.enter(session)
ctx.sessions.announce(session)
let liveDuringFlush = false
ctx.on('session/event', (_observed, event) => {
if (event.type === 'turn/start') detach()
})
ctx.on('session/flush', () => {
liveDuringFlush = ctx.sessions.get(session.id) === session
})
await ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'last' },
updateTrigger,
)
expect(session.events.map(event => event.type)).toEqual([
'turn/start',
'test/log-only',
'turn/end',
])
expect(liveDuringFlush).toBe(true)
expect(ctx.sessions.get(session.id)).toBeUndefined()
})
it('rejects detached sessions before opening a turn', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.prepare(SessionId('detached'))
await expect(ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'nope' },
updateTrigger,
)).rejects.toThrow('session "detached" is not live in this store')
expect(session.events).toEqual([])
})
it('leaves a balanced log when the durability checkpoint rejects', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('flush-failure'))
ctx.on('session/flush', () => { throw new Error('disk failed') })
await expect(ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'accepted' },
updateTrigger,
)).rejects.toThrow('disk failed')
expect(session.events.map(event => event.type)).toEqual([
'turn/start',
'test/log-only',
'turn/end',
])
})
it('rejects overlapping updates while the first append is still settling', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('overlap'))
let release!: () => void
const checkpoint = new Promise<void>((resolve) => {
release = resolve
})
ctx.on('session/flush', () => checkpoint)
const first = ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'first' },
updateTrigger,
)
await expect(ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'overlap' },
updateTrigger,
)).rejects.toThrow(/out-of-band append in progress/)
release()
await expect(first).resolves.toMatchObject({ data: { value: 'first' } })
})
})

View File

@@ -1,7 +1,13 @@
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import SessionStore, {
findLastMessageTurnEnd,
SESSION_FORMAT_VERSION,
Session,
SessionEvent,
SessionId,
} from '@deepseek-ai/dsh-session'
import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurface, TodoItem } from '@deepseek-ai/dsh-session'
describe('Session', () => {
@@ -48,6 +54,68 @@ describe('Session', () => {
expect(structuredClone(turnEnd.data.reason)).toEqual({ kind: 'max-tokens' })
})
it('finds the latest message-turn outcome past later non-message turns', () => {
const session = new Session(SessionId('message-turn-outcome'))
expect(findLastMessageTurnEnd(session.events)).toBeUndefined()
session.append('turn/start', {
turn: 1,
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'before' } },
})
session.append('context/message', {
content: [{ type: 'text', text: 'before' }],
source: { kind: 'plugin', plugin: 'before' },
}, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(findLastMessageTurnEnd(session.events)).toBeUndefined()
session.append('turn/start', {
turn: 2,
trigger: { kind: 'message', source: { kind: 'user' } },
})
session.append('user/message', {
content: [{ type: 'text', text: 'bounded prompt' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
const messageEnd = session.append('turn/end', { turn: 2, reason: { kind: 'max-tokens' } })
session.append('turn/start', {
turn: 3,
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'after' } },
})
session.append('context/message', {
content: [{ type: 'text', text: 'after' }],
source: { kind: 'plugin', plugin: 'after' },
}, { surfaceOp: 'append' })
session.append('turn/end', { turn: 3, reason: { kind: 'completed' } })
expect(findLastMessageTurnEnd(session.events)).toBe(messageEnd)
})
it('round-trips the coarse aborted turn outcome', () => {
const session = new Session(SessionId('aborted'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } })
const replayed = new Session(SessionId('aborted-replay'), structuredClone(session.events))
expect(replayed.events).toEqual(session.events)
const turnEnd = replayed.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
})
it('rejects legacy reason-bearing aborted outcomes at the seed/load boundary', () => {
const legacy = [
{
type: 'turn/start', seq: 0, time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
},
{
type: 'turn/end', seq: 1, time: 2,
data: { turn: 1, reason: { kind: 'aborted', reason: 'legacy cancellation detail' } },
},
] as unknown as SessionEvent[]
expect(() => new Session(SessionId('legacy-aborted'), legacy))
.toThrow('seed turn/end at index 1 uses unsupported reason-bearing aborted format')
})
it('renders context and steering messages as plain user content', () => {
const session = new Session(SessionId('s2'))
session.append('context/message', {
@@ -717,10 +785,11 @@ describe('SessionStore', () => {
// may create an unrelated property with the old implementation's name,
// but cannot suppress the durable event feed.
expect(Reflect.set(session, 'onAppend', undefined)).toBe(true)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
expect(events).toHaveLength(1)
expect(events[0]![0]).toBe(session)
expect(events[0]![1].type).toBe('user/message')
expect(events).toHaveLength(2)
expect(events[1]![0]).toBe(session)
expect(events[1]![1].type).toBe('user/message')
expect(ctx.sessions.get(session.id)).toBe(session)
expect(ctx.sessions.list()).toEqual([session])
@@ -732,6 +801,7 @@ describe('SessionStore', () => {
const a = ctx.sessions.create(SessionId('fixed'))
expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('already exists')
a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
a.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const forked = ctx.sessions.create(SessionId('fork'), { seed: [...a.events] })
expect(forked.deriveMessages()).toEqual(a.deriveMessages())
@@ -973,8 +1043,9 @@ describe('SessionStore', () => {
ctx.on('session/event', (_session, event) => void events.push(event))
const session = ctx.sessions.create(SessionId('fixed'))
expect(ctx.sessions.get(SessionId('fixed'))).toBe(session)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
expect(events).toHaveLength(1)
expect(events.at(-1)?.type).toBe('user/message')
})
it('contains session/event observer failures after the append commit point', async () => {
@@ -1058,6 +1129,8 @@ describe('SessionStore', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('surface-dispatch-veto'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('user/message', {
content: [{ type: 'text', text: 'source' }],
source: { kind: 'user' },
@@ -1077,19 +1150,19 @@ describe('SessionStore', () => {
step: 1,
content: [{ type: 'text', text: 'replacement' }],
}, {
surfaceOp: { op: 'replace', start: 0, end: 0 },
sourceEventSeqs: [0],
surfaceOp: { op: 'replace', start: 2, end: 2 },
sourceEventSeqs: [2],
})).toThrow('reject surface candidate')
expect(session.events).toHaveLength(1)
expect(surface.nodes).toEqual([0])
expect(session.events).toHaveLength(3)
expect(surface.nodes).toEqual([2])
expect(surface.replaceGeneration).toBe(0)
session.append('user/message', {
content: [{ type: 'text', text: 'next' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
expect(surface.nodes).toEqual([0, 1])
expect(surface.nodes).toEqual([2, 3])
expect(surface.replaceGeneration).toBe(0)
})

View File

@@ -30,6 +30,28 @@ function provenanceEvent(seq: number, sourceEventSeqs: unknown): SessionEvent {
} as unknown as SessionEvent
}
function toolResultEvent(
seq: number,
callId: string,
surfaceOp: SurfaceEvent['surfaceOp'] = 'append',
sourceEventSeqs?: number[],
): SessionEvent {
return {
type: 'tool/result',
seq,
time: seq,
data: {
turn: 1,
step: 1,
callId: CallId(callId),
content: [{ type: 'text', text: `result ${seq}` }],
isError: false,
},
surfaceOp,
...sourceEventSeqs === undefined ? {} : { sourceEventSeqs },
}
}
describe('foldSurface provenance', () => {
it('accepts absent or valid provenance and complete replacement coverage', () => {
const events = [
@@ -94,6 +116,33 @@ describe('foldSurface provenance', () => {
)
})
describe('foldSurface tool-result rewrites', () => {
it('rejects a replacement spanning multiple current nodes', () => {
const events = [
provenanceEvent(0, undefined),
provenanceEvent(1, undefined),
toolResultEvent(2, 'rewrite', { op: 'replace', start: 0, end: 1 }, [0, 1]),
]
expect(() => foldSurface(events)).toThrow(/must rewrite exactly one current node/)
})
it('rejects a replacement targeting a non-result node', () => {
const events = [
provenanceEvent(0, undefined),
toolResultEvent(1, 'rewrite', { op: 'replace', start: 0, end: 0 }, [0]),
]
expect(() => foldSurface(events)).toThrow(/must target a current tool\/result/)
})
it('rejects changes outside tool-result content', () => {
const events = [
toolResultEvent(0, 'original'),
toolResultEvent(1, 'changed', { op: 'replace', start: 0, end: 0 }, [0]),
]
expect(() => foldSurface(events)).toThrow(/may change only content/)
})
})
describe('SurfaceManager', () => {
it('shares ordered entries and nested replacement ranges with foldSurface', () => {
const s = new Session(SessionId('shared-fold'))