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
+3 -3
View File
@@ -32,9 +32,9 @@ async function unwrap<T>(response: RpcResponse<T>, dispose: () => Promise<void>)
/**
* Consume mux frames until the task turn ends, per the cli-demo runOneShot
* correlation precedent: anchor on the first turn/start whose trigger kind is
* 'message' (startup-injected turns are skipped), aggregate text from that
* turn's assistant/message events (last one wins), finish on its turn/end.
* correlation precedent: the stream opens immediately before the prompt, so
* its first observed turn/start owns the task. Aggregate text from that turn's
* assistant/message events (last one wins), then finish on its turn/end.
*/
async function consumeUntilTurnEnd(frames: AsyncIterable<RpcRequest<MuxFrame>>, sessionId: SessionId): Promise<TurnOutcome> {
let targetTurn: number | undefined
+1 -1
View File
@@ -92,7 +92,7 @@ async function seedResumeSession(cwd: string): Promise<void> {
{ type: 'step/end', seq: 5, time: 1_700_000_000_006, data: { turn: 1, step: 1 } },
{ type: 'session/title', seq: 6, time: 1_700_000_000_007, data: { title: 'Resume selector design', messageSeqs: [1], source: { kind: 'fallback' } } },
{ type: 'todo/write', seq: 7, time: 1_700_000_000_008, data: { todos: [{ content: 'Preserve restored state', status: 'in_progress' }] } },
{ type: 'turn/end', seq: 8, time: 1_700_000_000_009, data: { turn: 1, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 8, time: 1_700_000_000_009, data: { turn: 1, step: 1, reason: { kind: 'completed' } } },
]
const file = logPath(join(cwd, '.sessions'), sessionCwd, id, 'none')
await mkdir(dirname(file), { recursive: true })
+9 -9
View File
@@ -509,14 +509,14 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
expect(await terminal.snapshot({ includeScrollback: true }))
.toContain('/compact — Compact older conversation history')
const compact = ctx.compact as DeferredSnapshotCompactService
const compact = ctx.compact as unknown as DeferredSnapshotCompactService
const inbox: string[] = []
manualOrder = []
ctx.on('agent/inbox/enqueue', (subject, item) => {
if (subject === agent) inbox.push(`enqueue:${item.placement}:${item.id}`)
ctx.on('agent/inbox/inserted', (subject, { message }) => {
if (subject === agent) inbox.push(`inserted:${message.id}`)
})
ctx.on('agent/inbox/dequeue', (subject, message) => {
if (subject === agent) inbox.push(`dequeue:${message.id}`)
ctx.on('agent/inbox/claimed', (subject, { message }) => {
if (subject === agent) inbox.push(`claimed:${message.id}`)
})
ctx.on('session/event', (session, event) => {
if (session !== agent.session) return
@@ -531,7 +531,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
if (event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'compact') manualOrder?.push('checkpoint')
if (event.type === 'turn/start') manualOrder?.push(`turn/start:${event.data.trigger.kind}`)
if (event.type === 'turn/start') manualOrder?.push('turn/start')
})
ctx.on('session/flush', (session) => {
if (session === agent.session) manualOrder?.push('flush')
@@ -562,7 +562,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
content: [{ type: 'text', text: 'Injected while compaction was running.' }],
source: { kind: 'plugin', plugin: 'snapshot-injector' },
}))
expect(inbox[0]).toMatch(/^enqueue:queued:/u)
expect(inbox[0]).toMatch(/^inserted:/u)
expect(agent.status).toBe('idle')
expect(agent.session.events.some(event => event.type === 'user/message'
&& event.data.source.kind === 'user'
@@ -575,7 +575,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
agent.session.events.slice(-12).map(event => event.type).join(',')
}`)
await settleTerminal(terminal)
expect(inbox).toEqual([inbox[0], `dequeue:${inbox[0]?.slice('enqueue:queued:'.length) ?? ''}`])
expect(inbox).toEqual([inbox[0], `claimed:${inbox[0]?.slice('inserted:'.length) ?? ''}`])
}
const events: SessionEvent[] = [...agent.session.events]
@@ -641,7 +641,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
const compactStartIndex = manualTimeline.indexOf('compact/start')
const compactEndIndex = manualTimeline.indexOf('compact/end')
const firstFlushIndex = manualTimeline.indexOf('flush')
const queuedTurnIndex = manualTimeline.indexOf('turn/start:message')
const queuedTurnIndex = manualTimeline.indexOf('turn/start')
const commandDoneIndex = manualTimeline.indexOf('command/done')
expect(manualTimeline.filter(item => item === 'command/run')).toHaveLength(1)
expect(manualTimeline.filter(item => item === 'command/done')).toHaveLength(1)
+1 -1
View File
@@ -86,7 +86,7 @@ function withCompaction(raw: string): string {
lines.push(JSON.stringify({ ...event, seq: taken, time: time++ }))
return taken
}
at({ type: 'turn/start', data: { turn, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'compact' } } } })
at({ type: 'turn/start', data: { turn } })
const startSeq = at({ type: 'compact/start', data: { turn } })
const summarySeq = at({
type: 'compact/summary',
+11 -11
View File
@@ -460,7 +460,7 @@ Goal mutation accepted by one live agent. The matching `goal/change` session eve
Types: [Agent](../core-data-structures/core.md) · [GoalChanged](../core-data-structures/goal.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/goal/goal/src/domain.ts:135`](../../packages/goal/goal/src/domain.ts)
Source: [`packages/goal/goal/src/domain.ts:141`](../../packages/goal/goal/src/domain.ts)
## `llm/*`
@@ -481,7 +481,7 @@ The provider topology changed: an adapter registered or unregistered routes, or
'llm/adapters-updated'(): void
```
Source: [`packages/llm/llm/src/index.ts:68`](../../packages/llm/llm/src/index.ts)
Source: [`packages/llm/llm/src/index.ts:69`](../../packages/llm/llm/src/index.ts)
### `llm/stream` — waterfall
@@ -505,7 +505,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t
Types: [GenerateOptions](../core-data-structures/core.md) · [LlmService](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
Source: [`packages/llm/llm/src/index.ts:57`](../../packages/llm/llm/src/index.ts)
Source: [`packages/llm/llm/src/index.ts:58`](../../packages/llm/llm/src/index.ts)
## `session/*`
@@ -748,11 +748,11 @@ Source: [`packages/subagent/subagent/src/index.ts:131`](../../packages/subagent/
### `system-prompt/assemble` — waterfall
Expert waterfall over the assembled sections, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned value is authoritative. A supplied signal controls only this explicit assembly request and must not be retained to control later turns.
Expert waterfall over the assembled sections, contexts, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned value is authoritative. A supplied signal controls only this explicit assembly request and must not be retained to control later turns.
```ts cordis-catalog
/**
* Expert waterfall over the assembled sections, tools, and variables.
* Expert waterfall over the assembled sections, contexts, tools, and variables.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners
* receive only that scope's assemblies. The returned value is authoritative.
* A supplied signal controls only this explicit assembly request and must not
@@ -831,7 +831,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai
'tools/change'(): void
```
Source: [`packages/core/tools/src/index.ts:160`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:167`](../../packages/core/tools/src/index.ts)
### `tools/code-dispatch-log` — waterfall
@@ -855,7 +855,7 @@ Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bri
Types: [CodeDispatchLog](../core-data-structures/tools.md) · [ContentBlock](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [ToolRegistry](../core-data-structures/tools.md)
Source: [`packages/core/tools/src/index.ts:142`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:149`](../../packages/core/tools/src/index.ts)
### `tools/execute` — waterfall
@@ -877,7 +877,7 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor
Types: [Scoped](../core-data-structures/scope.md) · [ToolDispatchExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
Source: [`packages/core/tools/src/index.ts:117`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:124`](../../packages/core/tools/src/index.ts)
### `tools/post-execute` — waterfall
@@ -900,7 +900,7 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts
Types: [PostToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
Source: [`packages/core/tools/src/index.ts:129`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:136`](../../packages/core/tools/src/index.ts)
### `tools/pre-execute` — waterfall
@@ -921,7 +921,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv
Types: [PreToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
Source: [`packages/core/tools/src/index.ts:106`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:113`](../../packages/core/tools/src/index.ts)
### `tools/result` — emit
@@ -940,7 +940,7 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained
Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
Source: [`packages/core/tools/src/index.ts:150`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:157`](../../packages/core/tools/src/index.ts)
## `workflow/*`
+58 -19
View File
@@ -220,7 +220,7 @@ Source: [`packages/core/agent/src/index.ts:216`](../../packages/core/agent/src/i
## `ctx.approval` — `ApprovalService`
Approval service that applies session policy before answerers and logs every ask/outcome pair to the requesting session. It exposes deterministic policy changes to the model through prompt-submission notices.
Approval service that applies session policy before answerers and logs every ask/outcome pair to the requesting session. It exposes deterministic policy changes to the model through the cache-safe runtime-context snapshot and prompt-submission notices.
```ts cordis-catalog
/**
@@ -253,7 +253,7 @@ overrideOf(session: Session): ApprovalPolicy | undefined
Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalPolicy](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) · [Session](../core-data-structures/session.md)
Source: [`packages/ui/user-approval/src/index.ts:217`](../../packages/ui/user-approval/src/index.ts)
Source: [`packages/ui/user-approval/src/index.ts:210`](../../packages/ui/user-approval/src/index.ts)
## `ctx.bash` — `BashExecutor` (abstract seam)
@@ -328,7 +328,7 @@ Source: [`packages/bash/tool-bash/src/index.ts:104`](../../packages/bash/tool-ba
## `ctx.clientModuleHost` — `ClientModuleHostService`
The web plugin table service: incremental dshClient scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot sweep reports it).
The web plugin table service: incremental dshClient scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
```ts cordis-catalog
/**
@@ -368,7 +368,7 @@ onRebuilt(listener: (id: string, rev: string) => void): () => void
onGraphChanged(listener: () => void): () => void
```
Source: [`packages/client/modules/src/index.ts:143`](../../packages/client/modules/src/index.ts)
Source: [`packages/client/modules/src/index.ts:184`](../../packages/client/modules/src/index.ts)
## `ctx.codeRuntime` — `CodeRuntime` (abstract seam)
@@ -463,6 +463,26 @@ Abstract compaction service. Implementations own trigger policy, retention, and
*/
abstract compactIfNeeded( agent: CompactAgentContext, trigger: CompactionTrigger, signal: AbortSignal, ): Promise<CompactionResult | null>
/**
* Explicitly compact useful history even below automatic pressure thresholds.
* Implementations reserve idle turn admission synchronously before any
* asynchronous work, select a useful range without writing on a no-op, then
* append a standalone `compact/start` before summarization. That durable
* marker is the compaction lock until one `compact/end` attempt. Later waking
* prompts remain accepted in FIFO order and start only after the optional
* durability checkpoint and admission release. Context injected while the
* summary runs may sit between the marker pair; only the selected span must
* remain stable.
*
* @param agent - idle agent whose durable history should be compacted.
* @param signal - command-owned cancellation forwarded to summarization.
* @returns the compaction result, or `null` when no safe useful range exists.
* @throws {@link ManualCompactionError} for expected busy, changed-span,
* summarization/shrink, commit-stage, or persistence failures, and the exact
* abort reason when cancelled. Failed attempts remain visible in the log.
*/
abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, ): Promise<CompactionResult | null>
/**
* Forcibly compact a range of surface nodes into a single summary node.
* `start` and `end` name an inclusive span by surface position, not numeric seq
@@ -486,7 +506,7 @@ abstract compactRegion( start: number, end: number, agent: CompactAgentContext,
Types: [CompactionResult](../core-data-structures/compaction.md) · [CompactionTrigger](../core-data-structures/compaction.md)
Source: [`packages/compact/compact/src/index.ts:54`](../../packages/compact/compact/src/index.ts)
Source: [`packages/compact/compact/src/index.ts:80`](../../packages/compact/compact/src/index.ts)
## `ctx.credentials` — `Credentials` (abstract seam)
@@ -735,7 +755,7 @@ clear(agent: Agent, ref: GoalRef): GoalRef
Types: [Agent](../core-data-structures/core.md) · [CreateGoalRequest](../core-data-structures/goal.md) · [EditGoalRequest](../core-data-structures/goal.md) · [GoalBlockReason](../core-data-structures/goal.md) · [GoalRef](../core-data-structures/goal.md) · [GoalView](../core-data-structures/goal.md)
Source: [`packages/goal/goal/src/index.ts:197`](../../packages/goal/goal/src/index.ts)
Source: [`packages/goal/goal/src/index.ts:181`](../../packages/goal/goal/src/index.ts)
## `ctx.httpServer` — `HttpServerService`
@@ -880,7 +900,7 @@ stream(options: GenerateOptions): AsyncIterable<StreamChunk>
Types: [AdapterRegistrationHandle](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmConfigurableProvider](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
Source: [`packages/llm/llm/src/index.ts:229`](../../packages/llm/llm/src/index.ts)
Source: [`packages/llm/llm/src/index.ts:232`](../../packages/llm/llm/src/index.ts)
## `ctx.permission` — `PermissionService`
@@ -932,7 +952,7 @@ set(session: Session, name: string): void
Types: [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md)
Source: [`packages/ui/permission/src/index.ts:144`](../../packages/ui/permission/src/index.ts)
Source: [`packages/ui/permission/src/index.ts:159`](../../packages/ui/permission/src/index.ts)
## `ctx.planMode` — `PlanModeService`
@@ -968,7 +988,7 @@ set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/plan/plan-mode/src/index.ts:182`](../../packages/plan/plan-mode/src/index.ts)
Source: [`packages/plan/plan-mode/src/index.ts:183`](../../packages/plan/plan-mode/src/index.ts)
## `ctx.pty` — `PtyService`
@@ -1077,7 +1097,7 @@ Source: [`packages/sandbox/sandbox/src/index.ts:131`](../../packages/sandbox/san
## `ctx.sandboxPolicy` — `SandboxPolicyService`
The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mode and fallback workspace root. Tool layers call resolve for each execution so a session's mode log and immutable cwd travel together to every enforcing capability.
The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mode, fallback workspace root, and current request-time policy section. Tool layers call resolve for each execution so a session's mode log and immutable cwd travel together to every enforcing capability.
```ts cordis-catalog
/**
@@ -1101,7 +1121,7 @@ overrideOf(session: Session): SandboxMode | undefined
Types: [SandboxExecutionPolicy](../core-data-structures/sandbox.md) · [SandboxMode](../core-data-structures/sandbox.md) · [SandboxPolicyRequest](../core-data-structures/sandbox.md) · [Session](../core-data-structures/session.md)
Source: [`packages/sandbox/sandbox-policy/src/index.ts:68`](../../packages/sandbox/sandbox-policy/src/index.ts)
Source: [`packages/sandbox/sandbox-policy/src/index.ts:91`](../../packages/sandbox/sandbox-policy/src/index.ts)
## `ctx.sessionPersistence` — `SessionPersistence` (abstract seam)
@@ -1650,7 +1670,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId):
Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md)
Source: [`packages/core/session/src/index.ts:701`](../../packages/core/session/src/index.ts)
Source: [`packages/core/session/src/index.ts:720`](../../packages/core/session/src/index.ts)
## `ctx.sessionTitle` — `SessionTitleService`
@@ -2007,6 +2027,14 @@ Registry service for the prompt inputs assembled before each model step.
*/
section(section: PromptSection): () => void
/**
* Register ordered dynamic context in the calling context's scope. Scoped
* entries shadow global entries with the same name.
* @param context - the context contribution to register.
* @returns the exact Cordis effect disposer.
*/
context(context: PromptContext): () => void
/**
* Register a tool-schema provider in the calling context's scope. Global and
* matching scoped providers both contribute; returning the reserved
@@ -2036,9 +2064,9 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine
async assemble(context: AssembleContext = {}): Promise<PromptAssembly>
```
Types: [AssembleContext](../core-data-structures/system-prompt.md) · [PromptSection](../core-data-structures/system-prompt.md) · [ToolProviderResult](../core-data-structures/system-prompt.md)
Types: [AssembleContext](../core-data-structures/system-prompt.md) · [PromptContext](../core-data-structures/system-prompt.md) · [PromptSection](../core-data-structures/system-prompt.md) · [ToolProviderResult](../core-data-structures/system-prompt.md)
Source: [`packages/core/system-prompt/src/index.ts:248`](../../packages/core/system-prompt/src/index.ts)
Source: [`packages/core/system-prompt/src/index.ts:290`](../../packages/core/system-prompt/src/index.ts)
## `ctx.tasks` — `TaskService` (abstract seam)
@@ -2191,7 +2219,7 @@ estimateMessage(message: Message): number
Types: [EpochHeader](../core-data-structures/session.md) · [Message](../core-data-structures/core.md) · [Session](../core-data-structures/session.md) · [TokenMeasurement](../core-data-structures/token-meter.md)
Source: [`packages/llm/token-meter/src/index.ts:82`](../../packages/llm/token-meter/src/index.ts)
Source: [`packages/llm/token-meter/src/index.ts:85`](../../packages/llm/token-meter/src/index.ts)
## `ctx.toolResultPrune` — `ToolResultPruneService`
@@ -2311,7 +2339,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>
Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md)
Source: [`packages/core/tools/src/index.ts:704`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:711`](../../packages/core/tools/src/index.ts)
## `ctx.tui` — `TuiExtensionService` (abstract seam)
@@ -2334,7 +2362,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi
abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession
```
Source: [`packages/ui/tui/src/index.ts:241`](../../packages/ui/tui/src/index.ts)
Source: [`packages/ui/tui/src/index.ts:242`](../../packages/ui/tui/src/index.ts)
## `ctx.typert` — `TypertRegistry`
@@ -2510,7 +2538,7 @@ Durable workspace registry. Startup waits for `sessionPersistence`, builds one c
* original error and a non-directory rejects. Repeated calls for the same
* canonical path return the existing entity without changing its title.
* A newly created workspace is prepended to the durable registry order.
* A different canonical path cannot create a duplicate display title.
* Different canonical paths may share a display title.
* @param path - Existing directory to own, in any path spelling.
* @param title - Display title used only when a new record is created.
* @returns the existing or newly durable workspace.
@@ -2542,6 +2570,15 @@ list(): Workspace[]
*/
delete(id: WorkspaceId): Promise<boolean>
/**
* Archive one session durably. The session must exist (live or in session
* persistence); its workspace accounting — or lack of one — is irrelevant.
* An already archived id resolves without writing.
* @param sessionId - The session to archive.
* @returns resolution after durability.
*/
archiveSession(sessionId: SessionId): Promise<void>
/**
* Resolve by canonical directory path without creating or mutating a
* workspace. A missing path rejects during `realpath`; an existing unowned
@@ -2552,7 +2589,9 @@ delete(id: WorkspaceId): Promise<boolean>
async resolveByPath(path: string): Promise<Workspace | undefined>
```
Source: [`packages/workspace/workspace/src/index.ts:78`](../../packages/workspace/workspace/src/index.ts)
Types: [SessionId](../core-data-structures/core.md)
Source: [`packages/workspace/workspace/src/index.ts:81`](../../packages/workspace/workspace/src/index.ts)
## Inherited `ctx` members (cordis core + loader/hmr/timer)
@@ -42,7 +42,7 @@ async function seedReadOnlyParent(root: string, cwd: string): Promise<void> {
{ type: 'turn/start', seq: 0, time: 10, data: { turn: 1 } },
{ type: 'user/message', seq: 1, time: 11, data: createUserMessage({ content: [{ type: 'text', text: 'Tighten this session to read-only.' }], source: { kind: 'user' } }), surfaceOp: 'append' },
{ type: 'sandbox/mode', seq: 2, time: 12, data: { mode: 'read-only' } },
{ type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 3, time: 13, data: { turn: 1, step: 0, reason: { kind: 'completed' } } },
]
try {
await ctx.sessionPersistence.create(meta)
+3 -1
View File
@@ -172,7 +172,9 @@ describe('ACP prompt lifecycle', () => {
content: [{ type: 'text', text: 'autonomous work' }],
source: { kind: 'plugin', plugin: 'test' },
}))
await vi.waitFor(() => { expect(agent.status).toBe('running') })
await vi.waitFor(() => {
expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(true)
})
await harness.client.cancel({ sessionId })
await agent.whenIdle()
@@ -1477,23 +1477,20 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
nextTurn.set(sessionId, turn + 1)
retryScenarios.set(sessionId, { turn, stepStarted: true })
setRunning(sessionId, true)
append(sessionId, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
append(sessionId, { type: 'turn/start', data: { turn } })
append(sessionId, { type: 'user/message', surfaceOp: 'append', data: { content: text('请重试这个请求'), source: { kind: 'user' } } })
append(sessionId, { type: 'step/start', data: { turn, step: 1 } })
append(sessionId, { type: 'assistant/chunk', data: { turn, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
append(sessionId, { type: 'assistant/chunk', data: { turn, step: 1, chunk: { type: 'text-delta', index: 0, text: '应撤回的半截回复' } } })
append(sessionId, { type: 'step/end', data: { turn, step: 1 } })
},
/** Record one retry decision, then open the next retry turn. */
/** Record one retry decision; the next attempt remains in the same step. */
scheduleModelRetry(id: string, retry = 1, delayMs = 450): void {
const sessionId = sid(id)
const scenario = retryScenarios.get(sessionId)
if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`)
if (!scenario.stepStarted) {
append(sessionId, { type: 'step/start', data: { turn: scenario.turn, step: 1 } })
append(sessionId, { type: 'assistant/chunk', data: { turn: scenario.turn, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
append(sessionId, { type: 'assistant/chunk', data: { turn: scenario.turn, step: 1, chunk: { type: 'text-delta', index: 0, text: `${String(retry)} 次应撤回的回复` } } })
append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step: 1 } })
scenario.stepStarted = true
}
const failure = { code: 'TRANSPORT', message: '连接被重置' }
@@ -1505,14 +1502,6 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
retry, maxRetries: 2, delayMs, failure,
},
})
append(sessionId, {
type: 'turn/end',
data: { turn: scenario.turn, reason: { kind: 'error', step: 1, failure } },
})
const next = nextTurn.get(sessionId) ?? scenario.turn + 1
nextTurn.set(sessionId, next + 1)
append(sessionId, { type: 'turn/start', data: { turn: next, trigger: { kind: 'retry' } } })
scenario.turn = next
scenario.stepStarted = false
},
/** Record one retry decision, then cancel its source turn before the retry starts. */
@@ -1529,17 +1518,23 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
retry: 1, maxRetries: 2, delayMs, failure,
},
})
append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'aborted' } } })
append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step: 1 } })
append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, step: 1, reason: { kind: 'aborted', reason: { kind: 'user' } },
} })
retryScenarios.delete(sessionId)
setRunning(sessionId, false)
},
/** Finish the timing-hook retry with a finalized response in the open retry turn. */
/** Finish the timing-hook retry with a finalized response in the open step. */
completeModelRetry(id: string): void {
const sessionId = sid(id)
const scenario = retryScenarios.get(sessionId)
if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`)
retryScenarios.delete(sessionId)
append(sessionId, { type: 'step/start', data: { turn: scenario.turn, step: 1 } })
append(sessionId, { type: 'assistant/chunk', data: {
turn: scenario.turn,
step: 1,
chunk: { type: 'block-start', index: 0, blockType: 'text' },
} })
append(sessionId, {
type: 'assistant/message',
surfaceOp: 'append',
@@ -1550,7 +1545,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
},
})
append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step: 1 } })
append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'completed' } } })
append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, step: 1, reason: { kind: 'completed' } } })
setRunning(sessionId, false)
},
/** Log append WITHOUT the mux emit: a frame lost in transit — history still serves it, the client must repull. */
@@ -122,15 +122,15 @@ export interface ContextMessageNode {
source: unknown
}
/** Durable notice that a closed failed step is waiting for a model-request retry. */
/** Durable notice that a failed model request is waiting for another attempt. */
export type ModelRetryNode = LlmRetryEventData & {
kind: 'model-retry'
seq: number
/** Unix epoch ms from the llm/retry session event. */
time: number
/**
* Client-derived lifecycle: scheduled until a retry turn starts, started
* once it does, or cancelled when the failed turn aborts first.
* Client-derived lifecycle: scheduled until another attempt emits retry or
* chunk evidence, started once it does, or cancelled if the turn aborts first.
*/
retryState: 'scheduled' | 'started' | 'cancelled'
}
@@ -1,10 +1,13 @@
/**
* Convert a durable failure into copy that is safe to expose in the GUI.
* @param failure - Structured failure preserved by the session event.
* @param failure - Failure value preserved by the session event.
* @returns Display-safe copy for client projections.
*/
export function displayFailureMessage(failure: { code?: string; message: string }): string {
export function displayFailureMessage(failure: unknown): string {
if (failure === null || typeof failure !== 'object') return String(failure)
const record = failure as { code?: unknown; message?: unknown }
// Provider AUTH messages may echo a masked or partially preserved credential.
// Keep the raw diagnostic in the session log, but never project it into UI state.
return failure.code === 'AUTH' ? 'API key is invalid' : failure.message
if (record.code === 'AUTH') return 'API key is invalid'
return typeof record.message === 'string' ? record.message : JSON.stringify(failure)
}
@@ -360,9 +360,9 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
}
if (sourceEvent.type === 'turn/end' && sourceEvent.data.reason.kind === 'error') {
const reason = sourceEvent.data.reason
updateAssistant(ordinaryByStep.get(requestKey(sourceEvent.data.turn, reason.step)), {
updateAssistant(ordinaryByStep.get(requestKey(sourceEvent.data.turn, sourceEvent.data.step)), {
status: 'error',
error: displayFailureMessage('failure' in reason ? reason.failure : reason),
error: displayFailureMessage(reason.error),
})
continue
}
@@ -646,6 +646,7 @@ export class Session implements SessionFace {
if (this.partial !== null && this.partial.turn === data.turn && this.partial.step === data.step) {
this.partial = null
}
this.settleScheduledRetry('started', data.turn)
this.derivedNodes.push({
kind: 'model-retry',
seq: event.seq,
@@ -716,11 +717,10 @@ export class Session implements SessionFace {
return
}
switch (event.type) {
case 'turn/start': {
if (event.data.trigger.kind === 'retry') this.settleScheduledRetry('started')
case 'turn/start':
return
}
case 'assistant/chunk': {
this.settleScheduledRetry('started', event.data.turn)
const { turn, step, chunk } = event.data
if (this.partial === null || this.partial.turn !== turn || this.partial.step !== step) {
this.partial = new PartialAccumulator(turn, step)
@@ -748,22 +748,29 @@ export class Session implements SessionFace {
return
}
case 'turn/end': {
if (event.data.reason.kind === 'aborted' || event.data.reason.kind === 'disposed') {
if (event.data.reason.kind === 'error') {
this.settleScheduledRetry('started', event.data.turn)
} else if (event.data.reason.kind === 'aborted' || event.data.reason.kind === 'interrupted') {
this.settleScheduledRetry('cancelled', event.data.turn)
}
if (
event.data.reason.kind === 'error'
&& !this.derivedNodes.some(node => node.kind === 'model-retry' && node.turn === event.data.turn)
) {
const failure = 'failure' in event.data.reason ? event.data.reason.failure : event.data.reason
const failure = event.data.reason.error
const failedTurn = event.data.turn
const code = failure !== null && typeof failure === 'object'
&& typeof (failure as { code?: unknown }).code === 'string'
? (failure as { code: string }).code
: undefined
this.derivedNodes.push({
kind: 'turn-error',
seq: event.seq,
time: event.time,
turn: event.data.turn,
step: event.data.reason.step,
turn: failedTurn,
step: event.data.step,
message: displayFailureMessage(failure),
...(failure.code === undefined ? {} : { code: failure.code }),
...code === undefined ? {} : { code },
})
this.derivedRev++
}
@@ -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 =>
@@ -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' },
}),
]))
+41 -38
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 }) }
+1 -1
View File
@@ -11,7 +11,7 @@ import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compa
import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter'
import type { Session } from '@deepseek-ai/dsh-session'
import { CONTEXT_WINDOW_EXCEEDED_CODE, assertNever } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, LlmCallConfig } from '@deepseek-ai/dsh-llm'
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
// Type-only: makes the optional sibling service available to `ctx.get()`.
import type {} from '@deepseek-ai/dsh-compact-tool-result-prune'
@@ -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, reason: { kind: 'completed' } })
session.append('turn/end', { turn, step: 1, 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, reason: { kind: 'completed' } })
session.append('turn/end', { turn, step: 1, 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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 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, reason: { kind: 'completed' } })
closed.append('turn/end', { turn: 2, step: 0, 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, reason: { kind: 'completed' } })
session.append('turn/end', { turn, step: 1, reason: { kind: 'completed' } })
}
return [...session.events]
}
@@ -77,7 +77,7 @@ describe('real Loader composition', () => {
expect(unloaded).toEqual([])
expect(loaded.get('toolResultPrune')).toBeInstanceOf(ToolResultPruneService)
expect(loaded.get('compact')).toBeInstanceOf(BasicCompactService)
expect((loaded.compact as BasicCompactService).config).toMatchObject({
expect((loaded.compact as unknown as BasicCompactService).config).toMatchObject({
thresholdRatio: 0.5,
retainRatio: 0.125,
auto: false,
@@ -112,7 +112,7 @@ async function loopHarness(): Promise<LoopHarness> {
const agent = ctx.agentLoop.create(SessionId('manual-compact'), { provider: MODEL, model: MODEL })
const log: string[] = []
ctx.on('session/event', (_session, event) => {
if (event.type === 'turn/start') log.push(`turn/start:${event.data.trigger.kind}`)
if (event.type === 'turn/start') log.push('turn/start')
if (event.type === 'turn/end') log.push('turn/end')
if (event.type === 'compact/start') log.push(`compact/start:${String(event.data.turn)}`)
if (event.type === 'compact/summary') log.push('compact/summary')
@@ -169,7 +169,7 @@ function closedConversation(turns = 2, lastTurnNumber = turns): Session {
const session = new Session(SessionId(`closed-${turns}-${lastTurnNumber}`))
for (let index = 1; index <= turns; index += 1) {
const turn = index === turns ? lastTurnNumber : index
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `${PROMPT} ${turn}` }],
source: { kind: 'user' },
@@ -190,7 +190,7 @@ function closedConversation(turns = 2, lastTurnNumber = turns): Session {
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn, step: 1 })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
session.append('turn/end', { turn, step: 1, reason: { kind: 'completed' } })
}
return session
}
@@ -256,7 +256,7 @@ describe('compactNow through the real loop', () => {
const summary = log.indexOf('compact/summary')
const end = log.indexOf('compact/end:null')
const flush = log.indexOf('flush')
const nextTurn = log.indexOf('turn/start:message')
const nextTurn = log.indexOf('turn/start')
expect(start).toBeLessThan(summary)
expect(summary).toBeLessThan(end)
expect(end).toBeLessThan(flush)
@@ -424,8 +424,8 @@ describe('compactNow transaction and failure classification', () => {
const { compact } = detachedService()
const original = closedConversation(2)
original.append('compact/start', { turn: null })
original.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
original.append('turn/end', { turn: 3, reason: { kind: 'interrupted' } })
original.append('turn/start', { turn: 3 })
original.append('turn/end', { turn: 3, step: 0, reason: { kind: 'interrupted' } })
const reloaded = new Session(SessionId('reloaded-orphan'), [...original.events])
const agent = fakeAgent(reloaded, () => () => undefined)
@@ -436,7 +436,7 @@ describe('compactNow transaction and failure classification', () => {
it('refuses an open turn in the log', async () => {
const { compact } = detachedService()
const session = closedConversation(2)
session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 3 })
const agent = fakeAgent(session, () => () => undefined)
const error = await rejection(compact.compactNow(agent, SIGNAL))
@@ -814,7 +814,7 @@ describe('compactNow transaction and failure classification', () => {
it('excludes a manual request while an explicit region compaction runs', async () => {
const { compact } = detachedService()
const session = closedConversation(3)
session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 4 })
const agent = fakeAgent(session, () => () => undefined)
const gate = deferred()
compact.gate = gate.promise
@@ -60,7 +60,7 @@ function appendToolStep(
...extra,
}, { surfaceOp: 'append' })
session.append('step/end', { turn, step: 1 })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
session.append('turn/end', { turn, step: 1, reason: { kind: 'completed' } })
return result.seq
}
@@ -90,7 +90,7 @@ describe('compaction invariants', () => {
expect(() => replayed.append(
'turn/end',
{ turn: 1, reason: { kind: 'interrupted' } },
{ turn: 1, step: 0, 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, reason: { kind: 'interrupted' } })
source.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } })
replayed.append('turn/end', { turn: 2, step: 0, 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, reason: { kind: 'interrupted' } })
source.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } })
standalone.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } },
{ turn: 1, step: 0, 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, reason: { kind: 'completed' } },
{ turn: 1, step: 0, reason: { kind: 'completed' } },
)).not.toThrow()
})
@@ -271,8 +271,7 @@ describe('tool-pairing cache refresh', () => {
expect(eventIndexReads).toBe(3)
events.push({
type: 'turn/end', seq: 3, time: 3,
data: { turn: 1, reason: { kind: 'completed' } },
type: 'turn/end', seq: 3, time: 3, data: { turn: 1, step: 0, 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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: priorTurn, step: 0, 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, reason: { kind: 'aborted', reason: { kind: 'user' } } })
session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } })
expect(() => { ctx.emit('session/event', session, event(reading('1', '2', 'step context'))) })
.toThrow(/at a prompt boundary/)
})
@@ -42,14 +42,11 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'running',
acceptsNextStep: true,
ctx: new Context(),
send: () => {},
followup: () => {},
steer: () => {},
inject: () => { throw new Error('time-context must append directly to the open step') },
updateInbox: () => 'not-found',
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle: () => Promise.resolve(),
}
@@ -250,7 +247,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, reason: { kind: 'completed' } })
original.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
expect(JSON.stringify(original.deriveMessages())).not.toContain('Time sampled while preparing')
const resumed = new Session(SessionId('resumed'), [...original.events])
@@ -279,7 +276,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, reason: { kind: 'completed' } })
first.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
vi.setSystemTime(BASE + 500)
openMessageTurn(first, 2)
@@ -98,14 +98,11 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'running',
acceptsNextStep: true,
ctx: new Context(),
send: () => {},
followup: () => {},
steer: () => {},
inject: () => { throw new Error('tmux-context must append directly to the open step') },
updateInbox: () => 'not-found',
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle: () => Promise.resolve(),
}
@@ -212,12 +209,12 @@ describe('tmux-context injection', () => {
openMessageTurn(session, 1)
await fire(ctx, agent, 1, 1)
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
// Same state on turn 2: suppressed.
openMessageTurn(session, 2)
await fire(ctx, agent, 2, 1)
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 2, step: 0, reason: { kind: 'completed' } })
expect(contextTexts(session)).toHaveLength(1)
// Moved pane on turn 3: re-injected.
@@ -240,7 +237,7 @@ describe('tmux-context injection', () => {
openMessageTurn(session, 1)
await fire(ctx, agent, 1, 1)
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
// Changed state but inside the interval: suppressed, and never queried.
bash.result = runResult(`${tmuxLine({ paneId: '%99' })}\n`)
+3 -11
View File
@@ -922,7 +922,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'context(context: PromptContext): () => void',
jsDoc: '/**\n * Register ordered cache-safe dynamic context in the calling context\'s scope.\n * A scoped context shadows a global context with the same name; duplicates\n * within one layer and non-finite orders throw. Registration and disposal\n * emit `system-prompt/change`.\n * @param context - the context contribution to register.\n * @returns the exact Cordis effect disposer.\n */',
jsDoc: '/**\n * Register ordered dynamic context in the calling context\'s scope. Scoped\n * entries shadow global entries with the same name.\n * @param context - the context contribution to register.\n * @returns the exact Cordis effect disposer.\n */',
},
{
signature: 'tools(provider: (context: AssembleContext) => ToolProviderResult): () => void',
@@ -1537,7 +1537,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'Agent',
declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n send(message: UserMessage, options: SendOptions): void;\n reserveTurnAdmission(): (() => void) | undefined;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n}',
declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n}',
},
{
name: 'AgentCancelCause',
@@ -2303,14 +2303,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SearchResultView',
declaration: 'export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;',
},
{
name: 'SendOptions',
declaration: 'export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n}',
},
{
name: 'SendTarget',
declaration: 'export type SendTarget = \'next-turn\' | \'next-step\';',
},
{
name: 'Session',
declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}',
@@ -2325,7 +2317,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionEventMap',
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'steering/message\': {\n turn: number;\n message: UserMessage;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n \'request/context\': RequestContext;\n \'session/end-seed\': Record<string, never>;\n}',
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n };\n \'turn/end\': {\n turn: number;\n step: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'steering/message\': {\n turn: number;\n message: UserMessage;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n \'request/context\': RequestContext;\n \'session/end-seed\': Record<string, never>;\n}',
},
{
name: 'SessionEventMetadataFilter',
+4 -4
View File
@@ -212,8 +212,9 @@ export class ReactLoopAgent implements Agent {
try {
while (true) {
signal.throwIfAborted()
const step = ++phase.step
const step = phase.step + 1
this.session.append('step/start', { turn, step })
phase.step = step
try {
for (const message of decision.messages) {
this.session.append('user/message', message, { surfaceOp: 'append' })
@@ -249,7 +250,7 @@ export class ReactLoopAgent implements Agent {
} finally {
try {
// oxlint-disable-next-line typescript/no-non-null-assertion -- every exit assigns a turn ending
this.session.append('turn/end', { turn, reason: turnEnds! })
this.session.append('turn/end', { turn, step: phase.step, reason: turnEnds! })
} catch (error: unknown) {
this.throwError(error)
}
@@ -262,11 +263,10 @@ export class ReactLoopAgent implements Agent {
const { turn, step, abort: { signal } } = this.phase
signal.throwIfAborted()
const system = renderPrompt(assembly)
const boundaryMessages = this.session.deriveMessages()
while (true) {
const { request, preparedCall } = await this.buildRequest(
turn, step, assembly.tools, system, boundaryMessages, signal,
turn, step, assembly.tools, system, this.session.deriveMessages(), signal,
)
const assembler = new BlockAssembler()
const chunkSeqs: number[] = []
@@ -385,7 +385,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: { reason: { kind: 'error', error: 'broken continuation plugin' } },
data: { step: 1, reason: { kind: 'error', error: 'broken continuation plugin' } },
})
// the loop is still alive: a second send works normally
@@ -629,7 +629,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?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'error', error: failure })
expect(turnEnd).toMatchObject({ data: { step: 1, reason: { kind: 'error', error: failure } } })
// A failed step must not synthesize an assistant message.
expect(events.some(event => event.type === 'assistant/message')).toBe(false)
})
@@ -808,7 +808,7 @@ describe('turn and step boundary recovery', () => {
errors: 1,
})
expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({
data: { reason: { kind: 'error', error: 'reject step-start before commit' } },
data: { step: 0, reason: { kind: 'error', error: 'reject step-start before commit' } },
})
})
@@ -360,6 +360,7 @@ describe('request stability across the loop', () => {
expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({
data: {
step: 1,
reason: {
kind: 'error',
error: failure instanceof LlmError ? failure.failure : failure.message,
@@ -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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, step: 0, reason: { kind: 'completed' } } },
]
const session = ctx.sessions.create(sessionId, { seed })
await ctx.sessions.flush(session)
@@ -108,7 +108,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
surfaceOp: 'append',
},
{ type: 'step/end', seq: 4, time: 5, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 5, time: 6, data: { turn: 1, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 5, time: 6, data: { turn: 1, step: 1, reason: { kind: 'completed' } } },
] as unknown as SessionEvent[])
await first.ctx.fiber.dispose()
@@ -181,7 +181,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, reason: { kind: 'completed' } })
first.session.append('turn/end', { turn: 1, step: 0, 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'])
@@ -496,7 +496,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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, step: 0, 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: { reason: { kind: 'error', error: schedulerError.message } },
data: { step: 1, reason: { kind: 'error', error: schedulerError.message } },
})
})
})
-1
View File
@@ -29,7 +29,6 @@ function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
followup: () => {},
steer: () => {},
inject: () => {},
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle: () => Promise.resolve(),
}
+4
View File
@@ -87,6 +87,10 @@ function validateEvent(
if (trace.openStep !== null) {
fail(`turn/end ${event.data.turn} while step ${trace.openStep} is still open`)
}
const lastStep = trace.nextStep - 1
if (event.data.step !== lastStep) {
fail(`turn/end ${event.data.turn} expected last step ${lastStep}, got ${event.data.step}`)
}
openTurn = null
nextTurn += 1
break
+5 -1
View File
@@ -46,6 +46,7 @@ export const TOOL_OUTCOME_UNKNOWN = 'TOOL_OUTCOME_UNKNOWN'
export function interruptedTurnClosers(events: readonly SessionEvent[]): SessionEvent[] {
let openTurn: number | null = null
let openStep: number | null = null
let lastStep = 0
// Reset at each turn boundary so earlier calls cannot leak into tail repair.
// Assistant blocks register calls; later tool/call events add provenance seqs.
const pendingCalls = new Map<CallId, { step: number; callSeq?: number }>()
@@ -54,15 +55,18 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
case 'turn/start':
openTurn = event.data.turn
openStep = null
lastStep = 0
pendingCalls.clear()
break
case 'turn/end':
openTurn = null
openStep = null
lastStep = 0
pendingCalls.clear()
break
case 'step/start':
openStep = event.data.step
lastStep = event.data.step
break
case 'step/end':
pendingCalls.clear()
@@ -147,6 +151,6 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
if (openStep !== null) {
closers.push({ type: 'step/end', seq: seq++, time, data: { turn: openTurn, step: openStep } })
}
closers.push({ type: 'turn/end', seq: seq++, time, data: { turn: openTurn, reason: { kind: 'interrupted' } } })
closers.push({ type: 'turn/end', seq: seq++, time, data: { turn: openTurn, step: lastStep, reason: { kind: 'interrupted' } } })
return closers
}
+6 -5
View File
@@ -183,12 +183,13 @@ export interface SessionEventMap {
*/
'turn/start': { turn: number }
/**
* Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop
* awaits `session/flush` after an ordinary turn ends before claiming the next
* queued item. Success commits the turn; rejection is reported live and does
* not prevent later work.
* Closes turn `turn` after `step`, the last entered step (`0` when none),
* with the {@link TurnEndReason} that ended it. The loop awaits
* `session/flush` after an ordinary turn ends before claiming the next queued
* item. Success commits the turn; rejection is reported live and does not
* prevent later work.
*/
'turn/end': { turn: number; reason: TurnEndReason }
'turn/end': { turn: number; step: number; reason: TurnEndReason }
/** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */
'step/start': { turn: number; step: number }
/** Closes step `step` of turn `turn`. */
+1 -1
View File
@@ -29,7 +29,7 @@ function appendClosedTurn(
content: [{ type: 'text', text }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('turn/end', { turn, reason })
session.append('turn/end', { turn, step: 0, reason })
}
function appendOpenTurn(session: Session, turn: number): void {
+14 -14
View File
@@ -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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } },
data: { turn: 1, step: 0, 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, reason: { kind: 'completed' } }))
expect(() => open.append('turn/end', { turn: 2, step: 0, 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, reason: { kind: 'completed' } })
second.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
expect(() => second.append('turn/start', { turn: 3 }))
.toThrow(/expected turn 2, got 3/)
@@ -166,7 +166,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, reason: { kind: 'completed' } }))
expect(() => nested.append('turn/end', { turn: 1, step: 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', {
@@ -250,7 +250,7 @@ describe('session-log invariants', () => {
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
session.append('turn/start', { turn: 2 })
expect(() => session.append('tool/result', {
@@ -290,7 +290,7 @@ describe('session-log invariants', () => {
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
expect(() => session.append('tool/result', {
...original.data,
@@ -323,7 +323,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, reason: { kind: 'interrupted' } })
repaired.append('turn/end', { turn: 1, step: 1, reason: { kind: 'interrupted' } })
}).not.toThrow()
const unresolved = (await setup()).ctx.sessions.create()
@@ -332,7 +332,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, reason: { kind: 'error', error: 'boom' } })
unresolved.append('turn/end', { turn: 1, step: 1, reason: { kind: 'error', error: 'boom' } })
}).not.toThrow()
})
@@ -391,7 +391,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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, step: 0, 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: [
@@ -401,7 +401,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, reason: { kind: 'completed' } })).not.toThrow()
expect(() => open.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } } }),
fc.constant<Appendable>({ type: 'turn/end', data: { turn: 1, step: 0, 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 } } })),
+5 -5
View File
@@ -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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 1, time: 1, data: { turn: 1, step: 0, 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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 5, time: 5, data: { turn: 1, step: 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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 1, time: 500, data: { turn: 1, step: 0, 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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 1, time: 500, data: { turn: 1, step: 0, 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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 2, time: 9_500, data: { turn: 1, step: 0, reason: { kind: 'completed' } } },
]
expect(lastActivityTime(events)).toBe(9_500)
})
@@ -121,7 +121,7 @@ describe('Session.requestContext', () => {
/** A turn-enclosed capacity record; the invariant rejects one outside a turn. */
function seedWith(...records: { provider: string; model: string; contextWindow?: number }[]): SessionEvent[] {
const events: 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 },
}]
for (const data of records) {
events.push({ type: 'request/context', seq: events.length, time: 1, data })
+10 -10
View File
@@ -48,7 +48,7 @@ describe('Session', () => {
isError: false,
}),
}, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 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, reason: { kind: 'max-tokens' } })
session.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'aborted', reason: { kind: 'user' } } })
session.append('turn/end', { turn: 1, step: 0, 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')
@@ -132,7 +132,7 @@ describe('Session', () => {
},
}),
}, { surfaceOp: 'append' })
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
original.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
const replayed = new Session(SessionId('s3-replay'), [...original.events])
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
@@ -493,7 +493,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, reason: { kind: 'completed' as const } } }, // gap: expected seq 1
{ type: 'turn/end' as const, seq: 5, time: 2, data: { turn: 1, step: 0, reason: { kind: 'completed' as const } } }, // gap: expected seq 1
] as SessionEvent[]
expect(() => new Session(SessionId('seed-gap'), gapSeed)).toThrow(/contiguous|seq/)
})
@@ -508,7 +508,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, reason: { kind: 'completed' as const } } },
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, step: 0, reason: { kind: 'completed' as const } } },
] as SessionEvent[]
expect(() => new Session(SessionId('seed-no-marker'), markerlessSeed)).toThrow(/requires a surfaceOp marker/)
})
@@ -519,7 +519,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, reason: { kind: 'completed' as const } } },
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, step: 0, reason: { kind: 'completed' as const } } },
] as SessionEvent[]
const session = new Session(SessionId('seed-ok'), goodSeed)
expect(session.events.slice(0, 3)).toEqual(goodSeed)
@@ -708,7 +708,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, reason: { kind: 'completed' as const } } },
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, step: 0, 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
@@ -902,7 +902,7 @@ describe('Session', () => {
expect(() => { (before as SessionEvent[]).push(beforeEvent) }).toThrow(TypeError)
expect(() => { beforeEvent.data.turn = 99 }).toThrow(TypeError)
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
const after = session.events
expect(before).toHaveLength(1)
expect(after).toHaveLength(2)
@@ -1636,7 +1636,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, reason: { kind: 'completed' } })
original.append('turn/end', { turn: 1, step: 0, 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)
+4 -4
View File
@@ -36,7 +36,7 @@ function surfaceSession(): Session {
},
}),
}, { surfaceOp: 'append' })
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
s.append('turn/end', { turn: 1, step: 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, reason: { kind: 'completed' } })
s.append('turn/end', { turn: 1, step: 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, reason: { kind: 'completed' } })
s.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
// Chunks and boundaries are NOT in the surface, so only 2 messages.
expect(s.deriveMessages()).toHaveLength(2)
})
@@ -775,7 +775,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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 4, time: 5, data: { turn: 1, step: 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.
+2 -2
View File
@@ -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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
await ctx.plugin(InvariantService)
await expect(ctx.plugin(ToolsInvariant).then(() => undefined)).resolves.toBeUndefined()
})
+1 -1
View File
@@ -419,7 +419,7 @@ describe('runOneShot and executeCli', () => {
source: { kind: 'plugin', plugin: 'test' },
}))
other.append('turn/start', { turn: 1 })
other.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
other.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
})
const streamed: { sessionId: string; event: SessionEvent }[] = []
const result = runOneShot(ctx, {
@@ -40,8 +40,6 @@ function agent(ctx: Context, cwd: string): Agent {
followup: () => {},
steer: () => {},
inject: () => {},
updateInbox: () => 'not-found',
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle: () => Promise.resolve(),
}
@@ -29,13 +29,10 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session }
inbox,
ctx: new Context(),
get status() { return status },
get acceptsNextStep() { return status === 'running' },
send: () => {},
updateInbox: () => 'not-found',
followup: () => {},
steer: () => {},
inject(input) { inbox.append('next-step', input) },
reserveTurnAdmission: () => undefined,
cancel() { status = 'idle' },
whenIdle() { return Promise.resolve() },
}
@@ -994,7 +994,7 @@ describe('same-session goal driving', () => {
orphan.append('turn/start', {
turn: 1,
})
orphan.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
orphan.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } })
session.append('turn/end', { turn, step: 0, 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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 4, step: 0, reason: { kind: 'completed' } })
const stateSource = {
kind: 'goal', goalId: change.goal.id, revision: change.goal.revision, round: 0,
+3 -6
View File
@@ -38,13 +38,10 @@ function stubAgentForSession(session: Session): StubAgent {
inbox,
ctx: new Context(),
status: 'idle',
acceptsNextStep: false,
send: () => {},
updateInbox: () => 'not-found',
followup: () => {},
steer: () => {},
inject(input) { inbox.append('next-step', input) },
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle() { return Promise.resolve() },
}
@@ -76,7 +73,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, reason: { kind: 'completed' } })
session.append('turn/end', { turn, step: 0, reason: { kind: 'completed' } })
}
describe('GoalService creation and replay', () => {
@@ -584,7 +581,7 @@ describe('goal replay validation', () => {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'ordinary' }], source,
}), { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
session.append('turn/end', { turn, step: 0, reason: { kind: 'completed' } })
expect(foldGoal(session.events)).toEqual({ roundsStarted: 0 })
})
@@ -726,7 +723,7 @@ describe('goal replay validation', () => {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'missing' }], source,
}), { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
session.append('turn/end', { turn, step: 0, reason: { kind: 'completed' } })
expect(() => foldGoal(session.events)).toThrow('goal message source is invalid')
})
@@ -45,7 +45,6 @@ function liveAgent(ctx: Context, session: Session): Agent {
inject(input: UserMessage) {
inbox.append('next-step', input)
},
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle() { return Promise.resolve() },
}
@@ -38,7 +38,6 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent {
inject(input) {
this.inbox.append('next-step', input)
},
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle() { return Promise.resolve() },
}
@@ -66,7 +65,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, reason: { kind: 'completed' } })
stub.session.append('turn/end', { turn, step: 0, 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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, 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/)
-1
View File
@@ -66,7 +66,6 @@ export type {
// ---- Errors and ids ----
export { RpcId, transportError } from './rpc.ts'
export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } from './rpc.ts'
export type { InboxItemId } from '@deepseek-ai/dsh-agent/brand'
// ---- Fixed session-search product bounds ----
export {
@@ -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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 1, time: worked, data: { turn: 1, step: 0, reason: { kind: 'completed' } } },
],
meta: { cwd: '/proj', createdAt: 500 },
})
@@ -54,7 +54,7 @@ function liveAgent(ctx: Context, id: string, turns: number, openTail = false): S
content: [{ type: 'text', text: `prompt ${String(turn)}` }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
session.append('turn/end', { turn, step: 0, reason: { kind: 'completed' } })
}
if (openTail) {
session.append('turn/start', { turn: turns + 1 })
@@ -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, reason: { kind: 'completed' } })
session.append('turn/end', { turn, step: 0, 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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, 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', {
@@ -46,14 +46,11 @@ function stubAgent(session: Session): Agent {
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'idle',
acceptsNextStep: false,
ctx: new Context(),
send: () => {},
followup: () => {},
steer: () => {},
inject: () => {},
updateInbox: () => 'not-found',
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle: () => Promise.resolve(),
}
@@ -181,9 +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,
reason: { kind: 'aborted', reason: { kind: 'user' } },
closedTurn.append('turn/end', { turn: 1, step: 1, reason: { kind: 'aborted', reason: { kind: 'user' } },
})
expect(() => {
closedTurn.append('llm/retry', { turn: 1, step: 1, ...normal })
@@ -230,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, reason: { kind: 'completed' } })
nonFailureEnd.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
nonFailureEnd.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'idle context' }],
source: { kind: 'user' },
@@ -238,9 +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,
reason: { kind: 'error', error: failure },
missingStart.append('turn/end', { turn: 1, step: 0, reason: { kind: 'error', error: failure },
})
appendRetryTurn(missingStart, 2)
@@ -49,12 +49,8 @@ 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,
reason: {
kind: 'error',
error: { message: 'provider busy', code: 'RATE_LIMIT', status: 429 },
},
session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'error', error: { message: 'provider busy', code: 'RATE_LIMIT', status: 429 },
},
})
expect(session.deriveMessages()).toEqual([])
+2 -2
View File
@@ -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: { reason: { kind: 'error', error: { message: 'busy three', code: 'SERVER' } } },
data: { step: 1, reason: { kind: 'error', error: { message: 'busy three', code: 'SERVER' } } },
})
})
@@ -450,7 +450,7 @@ describe('provider-routed retry policy', () => {
expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false)
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'error', error: { code: 'NO_ADAPTER' } } },
data: { step: 1, reason: { kind: 'error', error: { code: 'NO_ADAPTER' } } },
})
})
@@ -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: { reason: { kind: 'error', error: { code: 'STREAM_CLOSED' } } },
data: { step: 1, reason: { kind: 'error', error: { code: 'STREAM_CLOSED' } } },
})
})
@@ -235,7 +235,7 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => {
expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(2)
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'error', error: { code: 'TRANSPORT' } } },
data: { step: 1, reason: { kind: 'error', error: { code: 'TRANSPORT' } } },
})
})
})
+8 -5
View File
@@ -9,8 +9,9 @@
* The state in force is folded from the session log (`plan/mode`, last one
* wins), so resume and fork restore it without a live mirror. User selections
* are held as pending intent until an in-turn step boundary. The service
* flushes from `agent/pre-step` before the affected request assembly;
* same-step request retries reuse their assembly.
* projects pending intent into the proposed step assembly, then flushes it
* from `agent/pre-step` only when the step is accepted. Same-step request
* retries reuse their assembly.
*
* The exit tool remains registered while plan mode is inactive so crossing a
* boundary changes only the prompt section, not the request tool catalog.
@@ -225,9 +226,11 @@ export class PlanModeService extends Service {
ctx.systemPrompt.section({
name: 'plan:policy',
order: 50,
text: context => context.agent !== undefined && foldPlanMode(context.agent.session.events)
? this.section
: '',
text: (context) => {
if (context.agent === undefined) return ''
const pending = this.pendingIntents.get(context.agent.session)
return (pending?.active ?? foldPlanMode(context.agent.session.events)) ? this.section : ''
},
})
// The plan projection unit (session-projection RFC): a pure double-event
@@ -31,8 +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, reason: { kind: 'completed' } },
type: 'turn/end', seq: 3, time: 3, data: { turn: 1, step: 0, reason: { kind: 'completed' } },
})
})
@@ -67,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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
await ctx.plugin(InvariantService, { enabled: true })
await expect(ctx.plugin(PlanModeInvariant).then(() => undefined)).rejects.toThrow(/expected a boolean/)
@@ -79,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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } })
session.append('turn/end', { turn, step: 0, reason: { kind: 'completed' } })
}
/** Append a minimal `request/header` snapshot so the log has a "what the model was told" anchor. */
@@ -799,17 +799,17 @@ describe('exit_plan_mode', () => {
expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false })
})
it('an approved exit keeps plan guidance until the boundary and never removes the tool', async () => {
it('an approved exit projects the next assembly before the boundary and never removes the tool', async () => {
const { ctx, agent } = await setupWithReview({ selected: ['Approve'] })
const approved = await callExit(ctx, agent)
expect(approved.isError).toBe(false)
// Calls of the SAME assistant response (no boundary between) were
// requested under the plan-shaped header — the fold stays plan for that
// whole batch; the boundary flush is what flips the next step.
// Calls of the SAME assistant response were requested under the existing
// plan-shaped header. Pending state shapes only the proposed next
// assembly; the accepted boundary then commits the matching durable fold.
expect(foldPlanMode(agent.session.events)).toBe(true)
const assembly = await ctx.systemPrompt.assemble({ agent })
expect(assembly.tools.some(tool => tool.name === EXIT_PLAN_MODE)).toBe(true)
expect(assembly.sections.find(section => section.name === 'plan:policy')?.text).toBe(TEST_PLAN_SECTION)
expect(assembly.sections.find(section => section.name === 'plan:policy')?.text).toBe('')
await boundary(ctx, agent, 'step-start')
expect(foldPlanMode(agent.session.events)).toBe(false)
const afterExit = await ctx.systemPrompt.assemble({ agent })
@@ -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, reason: { kind: 'completed' } })
session.append('turn/end', { turn, step: 0, reason: { kind: 'completed' } })
}
describe('plan projection unit', () => {
+6 -6
View File
@@ -43,8 +43,8 @@ function agent(ctx: Context, cwd?: string): Agent {
const session = new Session(id, undefined, { version: 0, id, createdAt: 0, ...cwd === undefined ? {} : { cwd } })
return {
id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'idle', acceptsNextStep: false, ctx,
send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined,
status: 'idle', ctx,
send: () => {},
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
}
}
@@ -258,8 +258,8 @@ describe('pty-local plugin shape', () => {
const ownerFiber = await ctx.plugin(() => {})
const owner: Agent = {
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx,
send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined,
status: 'idle', ctx: ownerFiber.ctx,
send: () => {},
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
}
ctx.agents.register(owner)
@@ -303,8 +303,8 @@ describe('pty-local plugin shape', () => {
const ownerFiber = await ctx.plugin(() => {})
const owner: Agent = {
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx,
send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined,
status: 'idle', ctx: ownerFiber.ctx,
send: () => {},
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
}
ctx.agents.register(owner)
+2 -2
View File
@@ -36,8 +36,8 @@ function stubAgent(ctx: Context, rawId: string): Agent {
const session = new Session(id)
return {
id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'idle', acceptsNextStep: false, ctx: scope.ctx,
send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined,
status: 'idle', ctx: scope.ctx,
send: () => {},
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
}
}
-2
View File
@@ -33,8 +33,6 @@ function stubAgent(ctx: Context, rawId: string): Agent {
followup: () => {},
steer: () => {},
inject: () => {},
updateInbox: () => 'not-found',
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle: () => Promise.resolve(),
}
@@ -50,8 +50,6 @@ function agent(ctx: Context, cwd: string): Agent {
followup: () => {},
steer: () => {},
inject: () => {},
updateInbox: () => 'not-found',
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle: () => Promise.resolve(),
}
@@ -46,8 +46,6 @@ function agent(ctx: Context, cwd: string | undefined): Agent {
followup: () => {},
steer: () => {},
inject: () => {},
updateInbox: () => 'not-found',
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle: () => Promise.resolve(),
}
@@ -41,8 +41,8 @@ function agent(ctx: Context): Agent {
const session = new Session(id)
const value: Agent = {
id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'idle', acceptsNextStep: false, ctx: scope.ctx,
send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined,
status: 'idle', ctx: scope.ctx,
send: () => {},
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
}
ctx.agents.register(value)
+2 -2
View File
@@ -19,8 +19,8 @@ function fakeAgent(ctx: Context, rawId: string): Agent {
const session = new Session(id)
const agent: Agent = {
id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'idle', acceptsNextStep: false, ctx: scope.ctx,
send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined,
status: 'idle', ctx: scope.ctx,
send: () => {},
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
}
ctx.agents.register(agent)
@@ -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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 6, time: 7, data: { turn: 1, step: 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, reason: { kind: 'completed' } } }),
JSON.stringify({ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, step: 0, 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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 11, time: 12, data: { turn: 3, step: 0, 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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 7, time: 10, data: { turn: 2, step: 0, 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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 7, time: 10, data: { turn: 2, step: 0, 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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 7, time: 10, data: { turn: 2, step: 0, 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, reason: { kind: 'completed' } })
b.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
a.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
b.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } } }),
JSON.stringify({ type: 'turn/end', seq: 3, time: 3, data: { turn: 1, step: 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, reason: { kind: 'completed' } } }),
JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, step: 0, 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, reason: { kind: 'completed' } } }),
JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, step: 0, 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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 9, time: 10, data: { turn: 1, step: 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, reason: { kind: 'completed' } } }),
JSON.stringify({ type: 'turn/end', seq: 4, time: 5, data: { turn: 1, step: 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, reason: { kind: 'completed' } } }),
JSON.stringify({ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, step: 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, reason: { kind: 'completed' } })
a.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 7, time: 10, data: { turn: 2, step: 0, 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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, step: 0, 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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, step: 0, 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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, step: 0, 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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 3, time: 3, data: { turn: 1, step: 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, reason: { kind: 'completed' } }), source_event_seqs: null, surface_op: null },
{ seq: 1, type: 'turn/end', time: 2, data: JSON.stringify({ turn: 1, step: 0, reason: { kind: 'completed' } }), source_event_seqs: null, surface_op: null },
]
expect(() => scanRows(withCorruptCommitted)).toThrow(/unparsable committed event/)
})
@@ -185,7 +185,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, reason: { kind: 'completed' } }))
insert.run(m.id, 2, 'turn/end', 3, JSON.stringify({ turn: 1, step: 0, reason: { kind: 'completed' } }))
db.close()
const mounted = await backend(path)
@@ -252,7 +252,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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 11, time: 10, data: { turn: 3, step: 0, 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])
@@ -478,7 +478,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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 7, time: 9, data: { turn: 2, step: 0, 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])
@@ -704,7 +704,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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, step: 0, reason: { kind: 'completed' } } },
]
// b1 commits seq 6..7 first.
await b1.ctx.sessionPersistence.append(m.id, turn2)
@@ -799,7 +799,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, reason: { kind: 'completed' } }),
data: JSON.stringify({ turn: 1, step: 0, reason: { kind: 'completed' } }),
source_event_seqs: null, surface_op: null },
]
const { preserved } = scanRows(rows)
@@ -831,7 +831,7 @@ describe('surface field round-trip', () => {
}),
}, { surfaceOp: 'append', sourceEventSeqs: [2] })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
await ctx.sessions.flush(session)
const loaded = await ctx.sessionPersistence.load(SessionId('roundtrip-surface'))
expect(loaded.events).toHaveLength(6)
@@ -857,7 +857,7 @@ describe('surface field round-trip', () => {
source: { kind: 'user' },
}),
}, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, 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')
@@ -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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 5, time: 6, data: { turn: 1, step: 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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 11, time: 10, data: { turn: 3, step: 0, 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])
@@ -115,7 +115,7 @@ function legacyMessageLog(): SessionEvent[] {
surfaceOp: { op: 'replace', start: 5, end: 5 },
},
{ type: 'step/end', seq: 8, time: 9, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 9, time: 10, data: { turn: 1, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 9, time: 10, data: { turn: 1, step: 1, reason: { kind: 'completed' } } },
] as unknown as SessionEvent[]
}
@@ -215,7 +215,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, reason: { kind: 'completed' } })
live.append('turn/end', { turn: 1, step: 0, 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
@@ -303,7 +303,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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
await ctx.sessions.flush(session)
const loaded = await ctx.sessionPersistence.load(SessionId('mutate'))
@@ -511,7 +511,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, reason: { kind: 'completed' } })
s2.append('turn/end', { turn: 2, step: 0, reason: { kind: 'completed' } })
await second.ctx.sessions.flush(s2)
const reloaded = await second.ctx.sessionPersistence.load(SessionId('resumed'))
@@ -536,7 +536,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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
const fiber = await fix.mount(ctx)
try {
@@ -560,7 +560,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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
// No explicit flush — dispose must drain.
await fiber.dispose()
@@ -588,7 +588,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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
await ctx.sessions.flush(session)
// Hot-reload: dispose instance 1, mount instance 2 over the same storage while the
@@ -600,7 +600,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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 2, step: 0, reason: { kind: 'completed' } })
await expect(ctx.sessions.flush(session)).resolves.not.toThrow()
const loaded = await ctx.sessionPersistence.load(SessionId('hmr-adopt'))
@@ -620,7 +620,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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
await ctx.sessions.flush(session)
// Append turn 2 to the LIVE session, then dispose instance 1 WITHOUT
@@ -628,7 +628,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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 2, step: 0, 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.
@@ -661,7 +661,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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
await ctx.sessions.flush(session)
const loaded = await ctx.sessionPersistence.load(SessionId('hmr-open'))
@@ -721,7 +721,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
}, { inject: ['sessions'] }))
await expect(ctx.sessions.flush(reuse)).resolves.toBeUndefined()
reuse.append('turn/start', { turn: 1 })
reuse.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
reuse.append('turn/end', { turn: 1, step: 0, 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])
@@ -742,7 +742,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, reason: { kind: 'completed' } })
first.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
await firstFiber.dispose()
// Disposal is an observe-only notification. Poll storage rather than
@@ -772,7 +772,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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, 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)
@@ -848,7 +848,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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, step: 0, reason: { kind: 'completed' } } },
], meta: { cwd: WORK, createdAt: 2000 } })
}, { inject: ['sessions'] }))
await ctx.sessions.flush(cont)
@@ -940,7 +940,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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, step: 0, 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])
@@ -1043,7 +1043,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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
await ctx.sessions.flush(session)
const loaded = await ctx.sessionPersistence.load(SessionId('flush-nostate'))
expect(loaded.events).toHaveLength(3)
@@ -1100,7 +1100,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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 11, time: 10, data: { turn: 3, step: 0, 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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } })
first.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } })
first.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } },
data: { turn: 1, step: 0, 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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } } })
events.push({ type: 'turn/end', seq: events.length, time: events.length, data: { turn: 1, step: 0, 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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 4, time: 4, data: { turn: 1, step: 0, 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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 4, time: 4, data: { turn: 2, step: 0, 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, reason: { kind: 'completed' } } }
const anchor: SessionEvent = { type: 'turn/end', seq: 9, time: 9, data: { turn: 2, step: 0, 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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 1, time: 1, data: { turn: 1, step: 0, 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, reason: { kind: 'error', error: 'needle failure' } } },
{ type: 'turn/end', seq: 3, time: 13, data: { turn: 1, step: 1, reason: { kind: 'error', error: 'needle failure' } } },
]
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 } })
@@ -136,7 +136,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, reason } })).toBe(text)
expect(extractSessionEventText({ type: 'turn/end', seq: 0, time: 1, data: { turn: 1, step: 1, reason } })).toBe(text)
}
const structural: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
@@ -167,7 +167,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, reason: { kind: 'interrupted' } } },
{ type: 'turn/end', seq: 3, time: 13, data: { turn: 1, step: 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, reason: { kind: 'completed' } })
seeded.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } })
parent.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } })
child.append('turn/end', { turn: 2, step: 0, 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, reason: { kind: 'completed' } })
child.append('turn/end', { turn: 3, step: 0, 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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 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, reason: { kind: 'completed' } })
seed.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
const requests: SessionTitleProviderRequest[] = []
const results: Array<ReturnType<typeof deferred<SessionTitleProviderResult>>> = []
ctx.sessionTitle.register({
@@ -46,13 +46,10 @@ function agentForCwd(cwd: string): Agent {
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'idle',
acceptsNextStep: false,
send: () => {},
followup: () => {},
steer: () => {},
inject: () => { throw new Error('step-boundary catalog must not use agent.inject()') },
updateInbox: () => 'not-found',
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle: () => Promise.resolve(),
}
@@ -65,14 +62,11 @@ function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent {
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
status: 'running',
acceptsNextStep: false,
ctx: new Context(),
send: () => {},
followup: () => {},
steer: () => {},
inject: () => { throw new Error('step-boundary catalog must not use agent.inject()') },
updateInbox: () => 'not-found',
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle: () => Promise.resolve(),
}
@@ -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, reason: { kind: 'aborted' } } },
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, step: 0, 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, reason: { kind: 'aborted' } } },
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, step: 0, 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, reason: { kind: 'stop' } } },
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, step: 0, reason: { kind: 'stop' } } },
],
}],
})
@@ -800,7 +800,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, reason: { kind: 'aborted' } } },
{ type: 'turn/end', seq: 2, time: 2, data: { turn: 1, step: 0, reason: { kind: 'aborted' } } },
],
}],
})
@@ -159,7 +159,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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: seq++, time: 0, data: { turn: 1, step: 1, reason: { kind: 'completed' } } },
]
expect(deriveReplayScript(events)).toEqual([{ kind: 'chunks', chunks: TEXT_CHUNKS }])
})
@@ -182,7 +182,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, reason: { kind: 'error', error: 'x' } } },
{ type: 'turn/end', seq: 3, time: 0, data: { turn: 1, step: 1, reason: { kind: 'error', error: 'x' } } },
]
expect(() => deriveReplayScript(events)).toThrow(/without a finish chunk.*replay\.override\.json/s)
})
@@ -30,8 +30,6 @@ function stubAgent(ctx: Context, rawId: string): Agent {
followup: () => {},
steer: () => {},
inject: () => {},
updateInbox: (): 'not-found' => 'not-found',
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle() { return Promise.resolve() },
}
@@ -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, reason: { kind: 'error', error: 'boom' } })
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'error', error: 'boom' } })
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, reason: { kind: 'error', error: 'boom' } })
session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'error', error: 'boom' } })
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, reason: { kind: 'completed' } })
child.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } })
child.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, 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, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, 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: 0, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
expect((await bench.tailProjections())?.values.todos).toEqual(list)
session.append('turn/start', { turn: 1 })
const cleared = await bench.tailProjections()
@@ -220,8 +220,8 @@ describe('new-session default', () => {
defaultPreset: 'danger-full-access',
})
const legacy = freshSession('legacy-source')
legacy.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
legacy.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
legacy.append('turn/start', { turn: 1 })
legacy.append('turn/end', { turn: 1, step: 0, 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([
-1
View File
@@ -225,7 +225,6 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
injectedOptions.push(input)
return input.id
},
reserveTurnAdmission: () => undefined,
cancel(cause) {
cancelled.push(cause)
},
+11 -25
View File
@@ -382,7 +382,7 @@ describe('TUI terminal-state snapshots', () => {
],
})
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
session.append('turn/start', {
turn: 2,
})
@@ -429,7 +429,7 @@ describe('TUI terminal-state snapshots', () => {
},
}),
}, { surfaceOp: 'append' })
harness.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
harness.session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
await checkpoint('retry-recovered', harness.terminal, { includeScrollback: true })
await disposeSnapshot(harness)
})
@@ -448,9 +448,7 @@ describe('TUI terminal-state snapshots', () => {
delayMs: 1_000,
failure: { message: 'temporary transport failure', code: 'TRANSPORT' },
})
harness.session.append('turn/end', {
turn: 1,
reason: { kind: 'aborted', reason: { kind: 'user' } },
harness.session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'aborted', reason: { kind: 'user' } },
})
})
await checkpoint('retry-cancelled', harness.terminal, { includeScrollback: true })
@@ -466,12 +464,8 @@ 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,
reason: {
kind: 'error',
error: { message: 'provider still unavailable', code: 'SERVER', status: 503 },
},
harness.session.append('turn/end', { turn: 1, step: 3, reason: { kind: 'error', error: { message: 'provider still unavailable', code: 'SERVER', status: 503 },
},
})
})
await checkpoint('retry-exhausted', harness.terminal, { includeScrollback: true })
@@ -647,9 +641,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,
reason: { kind: 'error', error: `Unsafe turn error ${CONTROL_PROBE}` },
session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'error', error: `Unsafe turn error ${CONTROL_PROBE}` },
})
},
}, { columns: 100, rows: 34 })
@@ -809,24 +801,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,
reason: { kind: 'error', error: 'provider stream failed after partial output' },
harness.session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'error', error: 'provider stream failed after partial output' },
})
harness.session.append('turn/start', { turn: 2 })
harness.session.append('turn/end', {
turn: 2,
reason: { kind: 'interrupted' },
harness.session.append('turn/end', { turn: 2, step: 0, reason: { kind: 'interrupted' },
})
harness.session.append('turn/start', { turn: 3 })
harness.session.append('turn/end', {
turn: 3,
reason: { kind: 'aborted', reason: { kind: 'disposed' } },
harness.session.append('turn/end', { turn: 3, step: 0, 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, reason: { kind: 'plugin-policy' } as never })
harness.session.append('turn/end', { turn: 4, step: 0, reason: { kind: 'plugin-policy' } as never })
})
await checkpoint('errors-and-help', harness.terminal, { includeScrollback: true })
@@ -877,7 +863,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, reason: { kind: 'completed' } } },
{ type: 'turn/end', seq: 6, time: Date.parse(`${day}T00:00:07Z`), data: { turn: 1, step: 1, reason: { kind: 'completed' } } },
{ type: 'session/title', seq: 7, time: Date.parse(`${day}T00:00:08Z`), data: { title, messageSeqs: [1], source: { kind: 'fallback' } } },
// A prior pickup, dated well after the work: the picker must still
// show the work's date, not the pickup's.

Some files were not shown because too many files have changed in this diff Show More