refactor(agent-loop): simplify message machine

This commit is contained in:
_Kerman
2026-07-30 13:49:57 +08:00
parent d554ae3019
commit f2e20c1ef0
212 changed files with 1326 additions and 2382 deletions

View File

@@ -59,7 +59,7 @@ afterEach(async () => {
})
function appendClosedTurn(session: Session): void {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hello' }],
source: { kind: 'user' },
@@ -208,7 +208,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
it('round-trip is byte-identical (incl. assistant/chunk verbatim)', async () => {
const m = meta('chunks')
const log: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
{ type: 'assistant/chunk', seq: 2, time: 3, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'he' } } },
{ type: 'assistant/chunk', seq: 3, time: 4, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'llo' } } },
@@ -342,7 +342,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
await mkdir(sessionDir(root, m.cwd, m.id), { recursive: true })
await writeFile(path, [
JSON.stringify(toHeaderLine(m)),
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
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, reason: { kind: 'completed' } } }),
'',
@@ -397,7 +397,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
// partial line with no newline (a torn fragment never fully flushed).
const path = rawLogPath(root, '/proj', m.id)
await writeFile(path, [
JSON.stringify({ type: 'turn/start', seq: 6, time: 8, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }),
JSON.stringify({ type: 'turn/start', seq: 6, time: 8, data: { turn: 2 } }),
JSON.stringify({ type: 'step/start', seq: 7, time: 9, data: { turn: 2, step: 1 } }),
'{"type":"assistant/chunk","seq":8,"ti', // truncated partial line (no newline)
].join('\n'), { flag: 'a' })
@@ -416,7 +416,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, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/start', seq: 10, time: 11, data: { turn: 3 } },
{ type: 'turn/end', seq: 11, time: 12, data: { turn: 3, reason: { kind: 'completed' } } },
] as SessionEvent[]
await ctx.sessionPersistence.append(m.id, turn3)
@@ -435,7 +435,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
await writeFile(rawLogPath(root, undefined, m.id), '\n{"partial', { flag: 'a' })
await ctx.sessionPersistence.load(m.id)
await ctx.sessionPersistence.append(m.id, [
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } },
{ 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')
@@ -463,7 +463,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
})
const turn2 = [
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } },
{ 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
@@ -501,7 +501,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
try {
await ctx.sessionPersistence.append(m.id, [
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } },
] as SessionEvent[])
throw new Error('expected append to reject')
} catch (error) {
@@ -526,7 +526,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
// metadata must be unaffected, so a later append still finds the right log.
mutableHeader(loaded.meta).cwd = '/evil'
await ctx.sessionPersistence.append(m.id, [
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } },
{ 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.
@@ -543,7 +543,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
data: { turn: 1 },
}])
await ctx.sessionPersistence.create(b)
await ctx.sessionPersistence.append(b.id, oneTurnLog())
@@ -596,8 +596,8 @@ describe('SessionPersistenceJsonl: write path (session/event → flush)', () =>
const a = ctx.sessions.create(SessionId('sa'))
const b = ctx.sessions.create(SessionId('sb'))
a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
b.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
a.append('turn/start', { turn: 1 })
b.append('turn/start', { turn: 1 })
a.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'A' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -678,7 +678,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => {
it('a seq gap after the last turn/end bounds the preserved tail (torn fragment tolerated)', () => {
const log = [
JSON.stringify({ type: 'session', version: 0, id: 'g', createdAt: 1, delegationDepth: 0 }),
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
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
].join('\n') + '\n'
// No committed turn/end, so the gap is a tolerated crash boundary: scanLog PRESERVES the
@@ -690,7 +690,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => {
it('rejects a seq gap BEFORE a later committed turn/end (committed data damaged)', () => {
const log = [
JSON.stringify({ type: 'session', version: 0, id: 'g2', createdAt: 1, delegationDepth: 0 }),
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
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, reason: { kind: 'completed' } } }),
].join('\n') + '\n'
@@ -719,7 +719,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => {
it('a corrupt line after the last turn/end bounds the preserved tail', () => {
const log = [
JSON.stringify({ type: 'session', version: 0, id: 'c2', createdAt: 1, delegationDepth: 0 }),
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }),
'{not json', // corrupt crash fragment, no turn/end committed
].join('\n') + '\n'
// The contiguous prefix (turn/start seq 0) is preserved; the corrupt
@@ -730,7 +730,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => {
it('tolerates a seq gap AFTER a turn/end (uncommitted tail)', () => {
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, trigger: { kind: 'message', source: { kind: 'user' } } } }),
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }),
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'
@@ -760,7 +760,7 @@ describe('SessionPersistenceJsonl: default packed chunk rows', () => {
data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: `t${k}` } },
}))
return [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
...deltas,
{ type: 'assistant/message', seq: 7, time: 8, data: {
@@ -851,7 +851,7 @@ describe('SessionPersistenceJsonl: default packed chunk rows', () => {
it('scanLog: a packed row advances the seq cursor by its whole run', () => {
const logText = [
JSON.stringify({ type: 'session', version: 0, id: 'rows', createdAt: 1, delegationDepth: 0 }),
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
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, reason: { kind: 'completed' } } }),
].join('\n') + '\n'
@@ -873,7 +873,7 @@ describe('SessionPersistenceJsonl: default packed chunk rows', () => {
it('scanLog: a packed row with a mid-run seq gap after the last turn/end drops the whole row', () => {
const logText = [
JSON.stringify({ type: 'session', version: 0, id: 'row-gap', createdAt: 1, delegationDepth: 0 }),
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }),
// seq0 skips 1 — the run's first member is already a gap; no turn/end follows.
JSON.stringify({ type: 'text-chunks', seq0: 2, time0: 2, data: { turn: 1, step: 1, index: 0, dt: [1, 1], texts: ['a', 'b', 'c'] } }),
].join('\n') + '\n'
@@ -1151,7 +1151,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
// A live session materializes and owns the id.
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, trigger: { kind: 'message', source: { kind: 'user' } } })
a.append('turn/start', { turn: 1 })
a.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}, { inject: ['sessions'] }))
for (const s of ctx.sessions.list()) await ctx.sessions.flush(s)
@@ -1229,7 +1229,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
await ctx2.plugin(SessionStore)
await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
await ctx2.sessionPersistence.append(m.id, [
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } },
{ type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
] as SessionEvent[])
const loaded = await ctx2.sessionPersistence.load(m.id)
@@ -1244,7 +1244,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
const m = meta('open-turn', '/h')
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.append(m.id, [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
] as SessionEvent[])
const { events } = await ctx.sessionPersistence.load(m.id)
expect(events.map(e => e.type)).toEqual(['turn/start', 'turn/end'])
@@ -1276,7 +1276,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
const session = ctx2.sessions.create(SessionId('flush-fail'))
// A full turn lands in the write-behind buffer.
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })

View File

@@ -285,7 +285,7 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => {
const path = logPath(root, header.cwd, header.id, 'zstd')
const before = await readFile(path)
const secondTurn = [
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
] as SessionEvent[]
await ctx.sessionPersistence.append(header.id, secondTurn)
@@ -380,7 +380,7 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => {
const path = logPath(root, header.cwd, header.id, 'zstd')
const committed = await readFile(path)
const openTurn = [
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
{ type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } },
{ type: 'assistant/chunk', seq: 8, time: 9, data: { turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: deterministicNoise(300_000) } } },
] as SessionEvent[]
@@ -427,7 +427,7 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => {
await ctx.sessionPersistence.append(header.id, oneTurnLog())
const path = logPath(root, header.cwd, header.id, 'zstd')
const secondTurn = [
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
{ 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')
@@ -475,7 +475,7 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => {
return realSync.call(this)
})
const secondTurn = [
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
{ 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/)