feat(core): make turn cancellation explicit

This commit is contained in:
Yichen Jiang
2026-07-16 18:12:34 +08:00
parent b1e19d8b69
commit c238992fbb
55 changed files with 884 additions and 383 deletions

View File

@@ -64,6 +64,8 @@ Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own t
Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings).
An interrupted live turn ends with the coarse `{ kind: 'aborted' }` outcome. Caller identity belongs to the Agent's runtime cancellation signal rather than the durable transcript; disposal remains the separate `{ kind: 'disposed' }` terminal state.
Every `SessionEvent` carries two optional top-level fields (structural metadata):
- `sourceEventSeqs?: number[]` — seq numbers of provenance sources (e.g., the `assistant/chunk` seqs behind an `assistant/message`, or the shadowed nodes behind a compaction replace node).

View File

@@ -93,7 +93,8 @@ export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap]
*/
export interface TurnEndReasonMap {
completed: { kind: 'completed' }
aborted: { kind: 'aborted'; reason?: string }
/** A cancellation request interrupted the live turn. */
aborted: { kind: 'aborted' }
/**
* The turn failed: a step threw or the model reported a failure. `step` is the
* step number the failure occurred on (the operational error's location — the

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

@@ -40,6 +40,16 @@ describe('Session', () => {
expect(structuredClone(turnEnd.data.reason)).toEqual({ kind: 'max-tokens' })
})
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('renders context and steering messages as tagged synthetic user content', () => {
const session = new Session(SessionId('s2'))
session.append('context/message', {