refactor(agent): simplify inbox-driven turn admission

This commit is contained in:
_Kerman
2026-08-02 00:27:37 +08:00
parent d38c8bfaf3
commit dbdf270af0
104 changed files with 550 additions and 511 deletions

View File

@@ -82,7 +82,12 @@ export const ev = {
},
}),
turnEnd: (seq: number, turn: number, reason: 'completed' | 'aborted' | 'disposed' = 'completed'): SessionEvent =>
at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }),
at(seq, { type: 'turn/end', data: {
turn,
reason: reason === 'completed'
? { kind: 'completed' }
: { kind: 'aborted', reason: { kind: reason === 'disposed' ? 'disposed' : 'user' } },
} }),
commandRun: (seq: number, commandId: string, name: string, args = ''): SessionEvent =>
at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }),
commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent =>

View File

@@ -225,20 +225,15 @@ describe('inspectRequests', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'step/start', { turn: 1, step: 1 }),
at(1, 'turn/end', {
turn: 1,
reason: {
kind: 'error',
step: 1,
failure: {
code: 'AUTH',
message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
},
turn: 1, step: 1, reason: { kind: 'error', error: {
code: 'AUTH',
message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
},
},
}),
at(2, 'step/start', { turn: 2, step: 1 }),
at(3, 'turn/end', {
turn: 2,
reason: { kind: 'error', step: 1, message: 'plugin exploded' },
turn: 2, step: 1, reason: { kind: 'error', error: 'plugin exploded' },
}),
]))

View File

@@ -162,7 +162,7 @@ describe('live event path', () => {
expect((last as { interrupted?: true }).interrupted).toBeUndefined()
})
it('retracts the failed step partial on retry and keeps a replayable notice before the recovered response', async () => {
it('retracts the failed-attempt partial and starts the retry on new chunk evidence', async () => {
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
const retryTurn = [
@@ -171,25 +171,13 @@ describe('live event path', () => {
ev.stepStart(8, 1),
ev.chunkStart(9, 1),
ev.chunkText(10, 1, '不完整回复'),
ev.stepEnd(11, 1),
ev.retry(12, 1, 0, 1, 2, 450, '连接被重置'),
at(13, {
type: 'turn/end',
data: {
turn: 1,
reason: {
kind: 'error', step: 0,
failure: { code: 'TRANSPORT', message: '连接被重置' },
},
},
}),
at(14, { type: 'turn/start', data: { turn: 2, trigger: { kind: 'retry' } } }),
ev.stepStart(15, 2),
ev.assistant(16, 2, '完整回复'),
ev.stepEnd(17, 2),
ev.turnEnd(18, 2),
ev.retry(11, 1, 0, 1, 2, 450, '连接被重置'),
ev.chunkStart(12, 1),
ev.assistant(13, 1, '完整回复'),
ev.stepEnd(14, 1),
ev.turnEnd(15, 1),
]
for (const event of retryTurn.slice(0, 7)) feed(event)
for (const event of retryTurn.slice(0, 6)) feed(event)
let snapshot = session.getSnapshot()
expect(snapshot.partial).toBeNull()
@@ -208,7 +196,7 @@ describe('live event path', () => {
})
expect(JSON.stringify(snapshot.nodes)).not.toContain('不完整回复')
for (const event of retryTurn.slice(7)) feed(event)
for (const event of retryTurn.slice(6)) feed(event)
snapshot = session.getSnapshot()
expect(snapshot.nodes.slice(-2).map(node => node.kind)).toEqual(['model-retry', 'assistant'])
expect(snapshot.nodes.some(node => node.kind === 'turn-error')).toBe(false)
@@ -230,33 +218,30 @@ describe('live event path', () => {
const failedTurns = [
ev.turnStart(6, 1),
ev.user(7, '鉴权失败'),
at(8, {
ev.stepStart(8, 1),
at(9, {
type: 'turn/end',
data: {
turn: 1,
reason: {
kind: 'error',
step: 0,
failure: {
code: 'AUTH',
message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
},
},
data: { turn: 1, step: 0, reason: { kind: 'error', error: {
code: 'AUTH',
message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
},
},
},
}),
ev.turnStart(9, 2),
ev.user(10, '内部失败'),
at(11, {
ev.turnStart(10, 2),
ev.user(11, '内部失败'),
ev.stepStart(12, 2, 1),
at(13, {
type: 'turn/end',
data: { turn: 2, reason: { kind: 'error', step: 1, message: 'plugin exploded' } },
data: { turn: 2, step: 1, reason: { kind: 'error', error: 'plugin exploded' } },
}),
]
for (const event of failedTurns) feed(event)
const errors = session.getSnapshot().nodes.filter(node => node.kind === 'turn-error')
expect(errors).toMatchObject([
{ seq: 8, turn: 1, step: 0, code: 'AUTH', message: 'API key is invalid' },
{ seq: 11, turn: 2, step: 1, message: 'plugin exploded' },
{ seq: 9, turn: 1, step: 0, code: 'AUTH', message: 'API key is invalid' },
{ seq: 13, turn: 2, step: 1, message: 'plugin exploded' },
])
expect('code' in errors[1]!).toBe(false)
@@ -395,7 +380,7 @@ describe('live event path', () => {
})
it.each(['aborted', 'disposed'] as const)(
'marks a scheduled retry as cancelled when its failed turn ends %s',
'marks a scheduled retry as cancelled when its failed turn receives the %s cause',
async (reason) => {
const { session } = await opened()
const feed = (event: SessionEvent) => {
@@ -415,6 +400,24 @@ describe('live event path', () => {
},
)
it('marks a scheduled retry as started when its failed turn ends with an error', async () => {
const { session } = await opened()
const feed = (event: SessionEvent) => {
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
}
feed(ev.turnStart(6, 1))
feed(ev.retry(7, 1))
feed(at(8, {
type: 'turn/end',
data: { turn: 1, step: 0, reason: { kind: 'error', error: 'retry failed' } },
}))
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
kind: 'model-retry',
retryState: 'started',
})
})
it('freezes an unfinalized partial into an interrupted node on turn/end (cancel path)', async () => {
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }