diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index f5277434f7..b79d51d973 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -32,9 +32,9 @@ async function unwrap(response: RpcResponse, dispose: () => Promise) /** * 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>, sessionId: SessionId): Promise { let targetTurn: number | undefined diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 4a1c02a053..e3973ee515 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -92,7 +92,7 @@ async function seedResumeSession(cwd: string): Promise { { 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 }) diff --git a/apps/cli/tests/tui.snapshot.ts b/apps/cli/tests/tui.snapshot.ts index 114d69401b..912fe80742 100644 --- a/apps/cli/tests/tui.snapshot.ts +++ b/apps/cli/tests/tui.snapshot.ts @@ -509,14 +509,14 @@ async function runScenario(scenario: Scenario): Promise { 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 { 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 { 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 { 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 { 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) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 6a4c079953..b49dc91b17 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -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', diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index ef38b3af12..23334bce7a 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -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/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 6238a9e497..2d82c69cdf 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -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 +/** + * 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 + /** * 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 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 ``` -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 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 +/** + * 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 + /** * 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 async resolveByPath(path: string): Promise ``` -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) diff --git a/examples/headless-agent/tests/subagent-inheritance.snapshot.ts b/examples/headless-agent/tests/subagent-inheritance.snapshot.ts index e5f6994d0c..9bc13b9230 100644 --- a/examples/headless-agent/tests/subagent-inheritance.snapshot.ts +++ b/examples/headless-agent/tests/subagent-inheritance.snapshot.ts @@ -42,7 +42,7 @@ async function seedReadOnlyParent(root: string, cwd: string): Promise { { 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) diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index d596f8766d..d4a4c51d23 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -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() diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 3a856d0220..327a2e6ef1 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -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. */ diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index a58f74c4e8..7291eec65c 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -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' } diff --git a/packages/client/runtime/src/client/sessions/failure-display.ts b/packages/client/runtime/src/client/sessions/failure-display.ts index 637329772b..88531f0857 100644 --- a/packages/client/runtime/src/client/sessions/failure-display.ts +++ b/packages/client/runtime/src/client/sessions/failure-display.ts @@ -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) } diff --git a/packages/client/runtime/src/client/sessions/request-inspection.ts b/packages/client/runtime/src/client/sessions/request-inspection.ts index 49ff8d974f..1962dd970a 100644 --- a/packages/client/runtime/src/client/sessions/request-inspection.ts +++ b/packages/client/runtime/src/client/sessions/request-inspection.ts @@ -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 } diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 1d022d933b..10ead47d3c 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -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++ } diff --git a/packages/client/runtime/tests/event-script.ts b/packages/client/runtime/tests/event-script.ts index 77bd402584..ebd64dc60f 100644 --- a/packages/client/runtime/tests/event-script.ts +++ b/packages/client/runtime/tests/event-script.ts @@ -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 => diff --git a/packages/client/runtime/tests/request-inspection.spec.ts b/packages/client/runtime/tests/request-inspection.spec.ts index f354d14654..509219cd5b 100644 --- a/packages/client/runtime/tests/request-inspection.spec.ts +++ b/packages/client/runtime/tests/request-inspection.spec.ts @@ -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' }, }), ])) diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 73bf901234..6df2763b5b 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -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 }) } diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 121f4459a2..2251aec7f1 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -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' diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 9a4f3d2221..78489a42f2 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -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]!, diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index b3dc707b27..d43ce4f2c9 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -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] } diff --git a/packages/compact/compact-basic/tests/loader-composition.spec.ts b/packages/compact/compact-basic/tests/loader-composition.spec.ts index dc75828f2e..162c44efd4 100644 --- a/packages/compact/compact-basic/tests/loader-composition.spec.ts +++ b/packages/compact/compact-basic/tests/loader-composition.spec.ts @@ -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, diff --git a/packages/compact/compact-basic/tests/manual-compact.spec.ts b/packages/compact/compact-basic/tests/manual-compact.spec.ts index aadf50f306..6bafa6dee8 100644 --- a/packages/compact/compact-basic/tests/manual-compact.spec.ts +++ b/packages/compact/compact-basic/tests/manual-compact.spec.ts @@ -112,7 +112,7 @@ async function loopHarness(): Promise { 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 diff --git a/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts b/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts index bd347e6d1e..e9190ca484 100644 --- a/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts +++ b/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts @@ -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 } diff --git a/packages/compact/compact/tests/invariant.spec.ts b/packages/compact/compact/tests/invariant.spec.ts index 9342fa193e..57795d9d3f 100644 --- a/packages/compact/compact/tests/invariant.spec.ts +++ b/packages/compact/compact/tests/invariant.spec.ts @@ -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() }) diff --git a/packages/compact/compact/tests/tool-pairing.spec.ts b/packages/compact/compact/tests/tool-pairing.spec.ts index 56f861fcf7..e122682051 100644 --- a/packages/compact/compact/tests/tool-pairing.spec.ts +++ b/packages/compact/compact/tests/tool-pairing.spec.ts @@ -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) diff --git a/packages/context/time-context/tests/invariant.spec.ts b/packages/context/time-context/tests/invariant.spec.ts index 35beb67406..4364a83385 100644 --- a/packages/context/time-context/tests/invariant.spec.ts +++ b/packages/context/time-context/tests/invariant.spec.ts @@ -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/) }) diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 99309d1db7..dae2e9f127 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -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) diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index 7a33cbfd8e..a67d5b03b2 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -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`) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index d323aa1b25..44d2dd68cc 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -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;\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;\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(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\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;\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;\n}', }, { name: 'SessionEventMetadataFilter', diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 0487f52095..70bdfc7744 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -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[] = [] diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index d4e520d958..c0cb5d44a4 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -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' } }, }) }) diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 7c2e864f83..9624fc796a 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -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, diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 0b937a53d2..05713df909 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -44,7 +44,7 @@ async function persistSession(sessionId: SessionId): Promise { // 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) diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index 7393122109..7f141eb2a5 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -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 } }, }) }) }) diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 0df68d7f1b..84646f62eb 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -29,7 +29,6 @@ function stubAgent(rawId: string, overrides: Partial = {}): Agent { followup: () => {}, steer: () => {}, inject: () => {}, - reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/core/session/src/invariant.ts b/packages/core/session/src/invariant.ts index f7e7292bc4..093dc2f3ef 100644 --- a/packages/core/session/src/invariant.ts +++ b/packages/core/session/src/invariant.ts @@ -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 diff --git a/packages/core/session/src/repair.ts b/packages/core/session/src/repair.ts index 1114156c2e..f1834e5bf6 100644 --- a/packages/core/session/src/repair.ts +++ b/packages/core/session/src/repair.ts @@ -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() @@ -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 } diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 691861e8b4..5a018aa08a 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -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`. */ diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index 039f0156d4..d8f012c21c 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -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 { diff --git a/packages/core/session/tests/invariant.spec.ts b/packages/core/session/tests/invariant.spec.ts index cf9cdc3ec8..3b9753d09a 100644 --- a/packages/core/session/tests/invariant.spec.ts +++ b/packages/core/session/tests/invariant.spec.ts @@ -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 () => { diff --git a/packages/core/session/tests/properties.spec.ts b/packages/core/session/tests/properties.spec.ts index 2cae9b3c4b..52b5b8d96c 100644 --- a/packages/core/session/tests/properties.spec.ts +++ b/packages/core/session/tests/properties.spec.ts @@ -71,7 +71,7 @@ const messageEventArb: fc.Arbitrary = fc.oneof( // A non-message event (trace/replay data — must NOT affect derived history). const nonMessageEventArb: fc.Arbitrary = fc.oneof( fc.constant({ type: 'turn/start', data: { turn: 1 } }), - fc.constant({ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }), + fc.constant({ type: 'turn/end', data: { turn: 1, step: 0, reason: { kind: 'completed' } } }), fc.constant({ type: 'step/start', data: { turn: 1, step: 1 } }), fc.constant({ 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 } } })), diff --git a/packages/core/session/tests/repair.spec.ts b/packages/core/session/tests/repair.spec.ts index b656a7116c..d84133fa77 100644 --- a/packages/core/session/tests/repair.spec.ts +++ b/packages/core/session/tests/repair.spec.ts @@ -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) }) diff --git a/packages/core/session/tests/request-header.spec.ts b/packages/core/session/tests/request-header.spec.ts index 81778909d4..2231730190 100644 --- a/packages/core/session/tests/request-header.spec.ts +++ b/packages/core/session/tests/request-header.spec.ts @@ -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 }) diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index a49924b950..ea6f417895 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -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) diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index 3b4f41b0d0..ea8d900cdf 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -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. diff --git a/packages/core/tools/tests/invariant.spec.ts b/packages/core/tools/tests/invariant.spec.ts index 80ae299da7..38eb6943b9 100644 --- a/packages/core/tools/tests/invariant.spec.ts +++ b/packages/core/tools/tests/invariant.spec.ts @@ -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() }) diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts index fd7d39303e..0080722a49 100644 --- a/packages/examples/cli-demo/tests/cli.spec.ts +++ b/packages/examples/cli-demo/tests/cli.spec.ts @@ -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, { diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 7256581e52..2f6379b320 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -40,8 +40,6 @@ function agent(ctx: Context, cwd: string): Agent { followup: () => {}, steer: () => {}, inject: () => {}, - updateInbox: () => 'not-found', - reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 0d72d092f9..521e63fdbc 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -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() }, } diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index 90ba77aab7..f3606d9db1 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -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'), diff --git a/packages/goal/goal-session/tests/invariant.spec.ts b/packages/goal/goal-session/tests/invariant.spec.ts index 6baaa724da..68a90dfa8b 100644 --- a/packages/goal/goal-session/tests/invariant.spec.ts +++ b/packages/goal/goal-session/tests/invariant.spec.ts @@ -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, diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 49d68a4c01..84005e7e8e 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -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') }) diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index 0435cf50b7..22b4a2e55b 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -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() }, } diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index a886f30a75..24a64aa7b4 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -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 = {}) { diff --git a/packages/hooks/hook-protocol/tests/invariant.spec.ts b/packages/hooks/hook-protocol/tests/invariant.spec.ts index 4efaae36c6..09984f220c 100644 --- a/packages/hooks/hook-protocol/tests/invariant.spec.ts +++ b/packages/hooks/hook-protocol/tests/invariant.spec.ts @@ -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/) diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 8c69bae934..4232476fd6 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -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 { diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index 0e82805ab3..aa909839b4 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -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 }, }) diff --git a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts index 6a6336d20a..fddc10f8d1 100644 --- a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts @@ -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 }) diff --git a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts index 15c7361024..db34d135e4 100644 --- a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts @@ -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 diff --git a/packages/host/apiproxy/tests/api-proxy-view.spec.ts b/packages/host/apiproxy/tests/api-proxy-view.spec.ts index 43083545db..4c14ea2c81 100644 --- a/packages/host/apiproxy/tests/api-proxy-view.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-view.spec.ts @@ -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', { diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index 664981c161..db07a1ee5e 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -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(), } diff --git a/packages/llm/llm-retry/tests/invariant.spec.ts b/packages/llm/llm-retry/tests/invariant.spec.ts index 09dc0bffe7..da47434ec9 100644 --- a/packages/llm/llm-retry/tests/invariant.spec.ts +++ b/packages/llm/llm-retry/tests/invariant.spec.ts @@ -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) diff --git a/packages/llm/llm-retry/tests/persistence.spec.ts b/packages/llm/llm-retry/tests/persistence.spec.ts index d8a21a5c08..8e4ab8bd41 100644 --- a/packages/llm/llm-retry/tests/persistence.spec.ts +++ b/packages/llm/llm-retry/tests/persistence.spec.ts @@ -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([]) diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index 99482126b8..a552166eca 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -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' } } }, }) }) diff --git a/packages/llm/llm-retry/tests/transport-recovery.spec.ts b/packages/llm/llm-retry/tests/transport-recovery.spec.ts index d7a3f56d7c..79d63d6ba4 100644 --- a/packages/llm/llm-retry/tests/transport-recovery.spec.ts +++ b/packages/llm/llm-retry/tests/transport-recovery.spec.ts @@ -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' } } }, }) }) }) diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index 7c3a6a1189..835b4b9036 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -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 diff --git a/packages/plan/plan-mode/tests/invariant.spec.ts b/packages/plan/plan-mode/tests/invariant.spec.ts index 4826036bf4..e33954706d 100644 --- a/packages/plan/plan-mode/tests/invariant.spec.ts +++ b/packages/plan/plan-mode/tests/invariant.spec.ts @@ -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() diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index 585fc17a73..48c139cd92 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -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 }) diff --git a/packages/plan/plan-mode/tests/projection.spec.ts b/packages/plan/plan-mode/tests/projection.spec.ts index c26112c8fd..02c92243d3 100644 --- a/packages/plan/plan-mode/tests/projection.spec.ts +++ b/packages/plan/plan-mode/tests/projection.spec.ts @@ -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', () => { diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index 0bb346067e..7a96e40429 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -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) diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index 9bfcdbdbff..4638b5ed8b 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -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(), } } diff --git a/packages/pty/pty/tests/service.spec.ts b/packages/pty/pty/tests/service.spec.ts index 66f5d6561b..61e13dc9da 100644 --- a/packages/pty/pty/tests/service.spec.ts +++ b/packages/pty/pty/tests/service.spec.ts @@ -33,8 +33,6 @@ function stubAgent(ctx: Context, rawId: string): Agent { followup: () => {}, steer: () => {}, inject: () => {}, - updateInbox: () => 'not-found', - reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts index 1b34a1aaaf..1df5ba20be 100644 --- a/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts @@ -50,8 +50,6 @@ function agent(ctx: Context, cwd: string): Agent { followup: () => {}, steer: () => {}, inject: () => {}, - updateInbox: () => 'not-found', - reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index a7979e7966..757b1be347 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -46,8 +46,6 @@ function agent(ctx: Context, cwd: string | undefined): Agent { followup: () => {}, steer: () => {}, inject: () => {}, - updateInbox: () => 'not-found', - reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/pty/tool-pty/tests/loader-composition.spec.ts b/packages/pty/tool-pty/tests/loader-composition.spec.ts index b6278c00cd..bdbaaaae91 100644 --- a/packages/pty/tool-pty/tests/loader-composition.spec.ts +++ b/packages/pty/tool-pty/tests/loader-composition.spec.ts @@ -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) diff --git a/packages/pty/tool-pty/tests/tools.spec.ts b/packages/pty/tool-pty/tests/tools.spec.ts index e76eaf54a7..e14d18862b 100644 --- a/packages/pty/tool-pty/tests/tools.spec.ts +++ b/packages/pty/tool-pty/tests/tools.spec.ts @@ -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) diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index c573948c2c..7d2957d641 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -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 } const origMat = backend.materialize.bind(backend) diff --git a/packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts index 566d649a79..bce455f81f 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts @@ -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) diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index da22c9bc00..cceda20040 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -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') diff --git a/packages/session-persistence/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts index ca9384b616..b98a643a0e 100644 --- a/packages/session-persistence/session-persistence/tests/contract.ts +++ b/packages/session-persistence/session-persistence/tests/contract.ts @@ -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 e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]) diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index a21675d93a..5cd4f57412 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -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]) diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index b9f7ebd361..01a0cd8909 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -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() } diff --git a/packages/session-projection/session-projection-cache/tests/cache.spec.ts b/packages/session-projection/session-projection-cache/tests/cache.spec.ts index 8474e21594..8ccddd6e1e 100644 --- a/packages/session-projection/session-projection-cache/tests/cache.spec.ts +++ b/packages/session-projection/session-projection-cache/tests/cache.spec.ts @@ -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 } diff --git a/packages/session-projection/session-projection/tests/registry.spec.ts b/packages/session-projection/session-projection/tests/registry.spec.ts index bd33914b2d..518722f2c7 100644 --- a/packages/session-projection/session-projection/tests/registry.spec.ts +++ b/packages/session-projection/session-projection/tests/registry.spec.ts @@ -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) diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index 69106375f0..36da11e99f 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -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 } }) diff --git a/packages/session-query/session-query/tests/search-helpers.spec.ts b/packages/session-query/session-query/tests/search-helpers.spec.ts index 957dca8996..2a186abe38 100644 --- a/packages/session-query/session-query/tests/search-helpers.spec.ts +++ b/packages/session-query/session-query/tests/search-helpers.spec.ts @@ -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', () => { diff --git a/packages/session-title/session-title-all-messages-llm/tests/provider.spec.ts b/packages/session-title/session-title-all-messages-llm/tests/provider.spec.ts index 5390607d23..be0470a59d 100644 --- a/packages/session-title/session-title-all-messages-llm/tests/provider.spec.ts +++ b/packages/session-title/session-title-all-messages-llm/tests/provider.spec.ts @@ -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) diff --git a/packages/session-title/session-title-llm/tests/llm.spec.ts b/packages/session-title/session-title-llm/tests/llm.spec.ts index 6572eb89c5..4996bf3586 100644 --- a/packages/session-title/session-title-llm/tests/llm.spec.ts +++ b/packages/session-title/session-title-llm/tests/llm.spec.ts @@ -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: [ diff --git a/packages/session-title/session-title/tests/persistence.spec.ts b/packages/session-title/session-title/tests/persistence.spec.ts index 7d5428983f..00449374de 100644 --- a/packages/session-title/session-title/tests/persistence.spec.ts +++ b/packages/session-title/session-title/tests/persistence.spec.ts @@ -30,7 +30,7 @@ async function appendPersistedTitle(ctx: Context, id: ReturnType { }) 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, diff --git a/packages/session-title/session-title/tests/service-contracts.spec.ts b/packages/session-title/session-title/tests/service-contracts.spec.ts index 632b7ec749..e724f2d768 100644 --- a/packages/session-title/session-title/tests/service-contracts.spec.ts +++ b/packages/session-title/session-title/tests/service-contracts.spec.ts @@ -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>> = [] ctx.sessionTitle.register({ diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index ad6693d3a9..7aff885936 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -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(), } diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index bb73e6cdcb..4cf69ef521 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -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' } } }, ], }], }) diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 5fa3cf75e3..90defa005a 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -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) }) diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index 924c073d61..d88e596e67 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -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() }, } diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts index 0f3b783b37..ea93d604b5 100644 --- a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts @@ -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) diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts index 410ea2d379..7280221e2c 100644 --- a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts +++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts @@ -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']) }) diff --git a/packages/todo/tool-todo/tests/projection.spec.ts b/packages/todo/tool-todo/tests/projection.spec.ts index 7090ddfca6..c605ed0878 100644 --- a/packages/todo/tool-todo/tests/projection.spec.ts +++ b/packages/todo/tool-todo/tests/projection.spec.ts @@ -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() diff --git a/packages/ui/permission/tests/permission.spec.ts b/packages/ui/permission/tests/permission.spec.ts index 0d80b4533b..51f4619aa4 100644 --- a/packages/ui/permission/tests/permission.spec.ts +++ b/packages/ui/permission/tests/permission.spec.ts @@ -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([ diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 8b0b10565e..61875eea23 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -225,7 +225,6 @@ export async function createTuiTestHarness undefined, cancel(cause) { cancelled.push(cause) }, diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 89d3f24711..0051278060 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -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. diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index cd767868c3..ef7014caf6 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -271,7 +271,7 @@ describe('goodbye message and /resume', () => { }), }, surfaceOp: 'append' }, { type: 'step/end', seq: 5, time: time + 5, data: { turn: 1, step: 1 } }, - { type: 'turn/end', seq: 6, time: time + 6, data: { turn: 1, reason } }, + { type: 'turn/end', seq: 6, time: time + 6, data: { turn: 1, step: 1, reason } }, { type: 'session/title', seq: 7, time: time + 7, data: { title, messageSeqs: [1], source: { kind: 'fallback' } } }, ] @@ -466,7 +466,7 @@ describe('goodbye message and /resume', () => { it.each([ [{ kind: 'aborted', reason: { kind: 'user' } }, 'cancelled'], - [{ kind: 'error', error: 'failed' }, 'error'], + [{ kind: 'error', step: 1, error: 'failed' }, 'error'], [{ kind: 'aborted', reason: { kind: 'disposed' } }, 'disposed'], [{ kind: 'max-tokens' }, 'max tokens'], [{ kind: 'interrupted' }, 'interrupted'], @@ -1372,12 +1372,10 @@ describe('pi-tui chat lifecycle and transcript', () => { }), { surfaceOp: 'append' }) appendAssistant(result.session, []) result.session.append('step/end', { turn: 1, step: 1 }) - result.session.append('turn/end', { - turn: 1, - reason: { kind: 'aborted', reason: { kind: 'user' } }, + result.session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'aborted', reason: { kind: 'user' } }, }) result.session.append('turn/start', { turn: 2 }) - result.session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) + result.session.append('turn/end', { turn: 2, step: 0, reason: { kind: 'completed' } }) result.session.append('turn/start', { turn: 3 }) result.session.append('step/start', { turn: 3, step: 1 }) result.session.append('assistant/chunk', { @@ -1871,7 +1869,7 @@ describe('pi-tui chat lifecycle and transcript', () => { session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'done' } }) clock += 1_000 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' } }) }, }) result.agent.status = 'running' @@ -4001,31 +3999,25 @@ describe('pi-tui chat lifecycle and transcript', () => { agentEvents(events.ctx, unrelatedAgent).emit('agent/disposed') agentEvents(events.ctx, events.agent).emit('agent/error', 1, 1, new Error('live failure')) events.session.append('step/end', { turn: 1, step: 1 }) - events.session.append('turn/end', { turn: 1, reason: { kind: 'error', error: 'live failure' } }) + events.session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'error', error: 'live failure' } }) events.session.append('turn/start', { turn: 2 }) - events.session.append('turn/end', { turn: 2, reason: { kind: 'error', error: 'durable failure' } }) + events.session.append('turn/end', { turn: 2, step: 0, reason: { kind: 'error', error: 'durable failure' } }) events.session.append('turn/start', { turn: 3 }) - events.session.append('turn/end', { - turn: 3, - reason: { kind: 'aborted', reason: { kind: 'user' } }, + events.session.append('turn/end', { turn: 3, step: 0, reason: { kind: 'aborted', reason: { kind: 'user' } }, }) events.session.append('turn/start', { turn: 4 }) - events.session.append('turn/end', { turn: 4, reason: { kind: 'max-tokens' } }) + events.session.append('turn/end', { turn: 4, step: 0, reason: { kind: 'max-tokens' } }) events.session.append('turn/start', { turn: 5 }) - events.session.append('turn/end', { turn: 5, reason: { kind: 'interrupted' } }) + events.session.append('turn/end', { turn: 5, step: 0, reason: { kind: 'interrupted' } }) events.session.append('turn/start', { turn: 6 }) - events.session.append('turn/end', { - turn: 6, - reason: { kind: 'error', error: { message: 'structured provider failure', code: 'SERVER' } }, + events.session.append('turn/end', { turn: 6, step: 0, reason: { kind: 'error', error: { message: 'structured provider failure', code: 'SERVER' } }, }) events.session.append('turn/start', { turn: 8 }) - events.session.append('turn/end', { - turn: 8, - reason: { kind: 'aborted', reason: { kind: 'disposed' } }, + events.session.append('turn/end', { turn: 8, step: 0, reason: { kind: 'aborted', reason: { kind: 'disposed' } }, }) events.session.append('turn/start', { turn: 9 }) // Merge-extensible reason kind unknown to the TUI still names the stop. - events.session.append('turn/end', { turn: 9, reason: { kind: 'plugin-policy' } as never }) + events.session.append('turn/end', { turn: 9, step: 0, reason: { kind: 'plugin-policy' } as never }) agentEvents(events.ctx, events.agent).emit('agent/disposed') await tick() expect(events.terminal.output).toContain('live failure') @@ -5454,8 +5446,8 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.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(), }) const terminal = new FakeTerminal() @@ -5481,8 +5473,8 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.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(), }) const terminal = new FakeTerminal() @@ -5518,8 +5510,8 @@ describe('terminal mounting', () => { const otherSession = ctx.sessions.create(SessionId('other-session')) ctx.agents.register({ id: otherSession.id, options: {}, session: otherSession, inbox: new Inbox(otherSession, { inserted: () => {}, discarded: () => {} }), - status: 'idle', acceptsNextStep: false, ctx, - send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, + status: 'idle', ctx, + send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), }) expect(terminal.started).toBe(0) @@ -5527,8 +5519,8 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('late-session')) const agent = { id: session.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(), } as Agent ctx.agents.register(agent) @@ -5560,8 +5552,8 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main-session')) ctx.agents.register({ id: session.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(), }) await tick() @@ -5606,8 +5598,8 @@ describe('terminal mounting', () => { session.append('step/start', { turn: 1, step: 1 }) ctx.agents.register({ id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }), - status: 'running', acceptsNextStep: true, ctx, - send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, + status: 'running', ctx, + send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() diff --git a/packages/ui/user-approval/src/index.ts b/packages/ui/user-approval/src/index.ts index 94805465d5..2abb94e130 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -102,6 +102,22 @@ const NEVER_SENTENCE = 'Approval prompts are disabled in this session: actions t /** Model-facing statement for an interactive policy that may still fail closed. */ const ASK_SENTENCE = 'Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.' +/** Read the latest visible policy from the runtime-context projection owned by system-prompt. */ +function toldApprovalPolicy(session: Session): ApprovalPolicy | undefined { + const messages = session.deriveMessages() + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index] + if (message?.source.kind !== 'plugin' || message.source.plugin !== '@deepseek-ai/dsh-system-prompt') continue + for (const block of message.content) { + if (block.type !== 'text') continue + if (block.text.includes(NEVER_SENTENCE)) return 'never' + if (block.text.includes(ASK_SENTENCE)) return 'ask' + } + return undefined + } + return undefined +} + /** * The session's approval-policy override: the last `approval/policy` event in * the log, or undefined when the session never switched (callers apply the @@ -249,8 +265,7 @@ export class ApprovalService extends Service { // Same fold effectivePolicy performs — override is scanned here anyway // for POSITIONAL attribution; the default lives once, in the method. const current = this.effectivePolicy(session) - const header = session.requestHeader() - const told = toldApprovalPolicy(header?.system) + const told = toldApprovalPolicy(session) // Cold start (nothing ever told) narrates nothing — the section about // to go out states the truth, and there is no delta to explain. if (told === undefined || told === current) return decision diff --git a/packages/ui/user-approval/tests/approval.spec.ts b/packages/ui/user-approval/tests/approval.spec.ts index 92e15ba2ab..0738d31b65 100644 --- a/packages/ui/user-approval/tests/approval.spec.ts +++ b/packages/ui/user-approval/tests/approval.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' -import { CallId } from '@deepseek-ai/dsh-llm' +import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' +import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm' import { carrierKeyOf, createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' @@ -352,8 +352,6 @@ describe('ApprovalService.request', () => { describe('approval policy (the approval/policy fold)', () => { const NEVER_SENTENCE = 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).' const ASK_SENTENCE = 'Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.' - const ASK_MARKER = '' - const NEVER_MARKER = '' /** * An agent stand-in over a REAL Session — gate, section, and narrator fold @@ -372,22 +370,23 @@ describe('approval policy (the approval/policy fold)', () => { const submitPrompt = async (ctx: Context, agent: Agent): Promise => { const signal = new AbortController().signal + const configured = ctx.get('approval')?.config.policy ?? 'ask' + const current = effectiveApprovalPolicy(agent.session.events) ?? configured + const runtimeContext = createUserMessage({ + content: [{ type: 'text', text: current === 'never' ? NEVER_SENTENCE : ASK_SENTENCE }], + source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }, + }) const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', [], { turn: 1, step: 1, signal }, - () => Promise.resolve({ kind: 'enter' as const, messages: [] }), + () => Promise.resolve({ kind: 'enter' as const, messages: [runtimeContext] }), ) if (decision.kind === 'enter') { for (const message of decision.messages) { agent.session.append('user/message', message, { surfaceOp: 'append' }) } - const configured = ctx.get('approval')?.config.policy ?? 'ask' - const current = effectiveApprovalPolicy(agent.session.events) ?? configured - appendHeader( - agent.session, - current === 'never' ? `${NEVER_SENTENCE}\n${NEVER_MARKER}` : ASK_MARKER, - ) + appendHeader(agent.session) } } @@ -398,9 +397,17 @@ describe('approval policy (the approval/policy fold)', () => { ? [event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')] : []) - /** Append a `request/header` snapshot whose system text is exactly `system`. */ - function appendHeader(session: Session, system: string): void { - session.append('request/header', { header: { config: { provider: 'mock', model: 'mock' }, system }, reason: 'initial' }) + /** Append the stable system header that follows one entered prompt. */ + function appendHeader(session: Session): void { + session.append('request/header', { header: { config: { provider: 'mock', model: 'mock' }, system: 'persona' }, reason: 'initial' }) + } + + /** Append one model-visible runtime-context snapshot owned by system-prompt. */ + function appendToldPolicy(session: Session, policy: 'ask' | 'never'): void { + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: policy === 'never' ? NEVER_SENTENCE : ASK_SENTENCE }], + source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }, + }), { surfaceOp: 'append' }) } it('folds to the last event, or undefined without one', () => { @@ -542,7 +549,8 @@ describe('approval policy (the approval/policy fold)', () => { const ctx = new Context() await ctx.plugin(ApprovalService) const { agent, session } = sessionAgent('sess-narr-rejected') - appendHeader(session, ASK_MARKER) + appendToldPolicy(session, 'ask') + appendHeader(session) setApprovalPolicy(session, 'never') const signal = new AbortController().signal @@ -557,13 +565,14 @@ describe('approval policy (the approval/policy fold)', () => { expect(narrations(session)).toEqual([]) }) - it('reads what the model was told back from the folded header text after a restart', async () => { - // A session whose last request carried the never sentence resumes under + it('reads what the model was told from visible runtime context after a restart', async () => { + // A session whose retained context stated never resumes under // an ask default: the narrator attributes the change to the operator. const ctx = new Context() await ctx.plugin(ApprovalService) const { agent, session } = sessionAgent('sess-narr-2') - appendHeader(session, `persona\n\n${NEVER_SENTENCE}\n${NEVER_MARKER}`) + appendToldPolicy(session, 'never') + appendHeader(session) await submitPrompt(ctx, agent) expect(narrations(session)).toEqual(['The approval policy changed from "never" to "ask" (changed by the operator/config).']) }) @@ -581,7 +590,8 @@ describe('approval policy (the approval/policy fold)', () => { }) await ctx.plugin(ApprovalService) const { agent, session } = sessionAgent('sess-narr-retry') - appendHeader(session, ASK_MARKER) + appendToldPolicy(session, 'ask') + appendHeader(session) setApprovalPolicy(session, 'never') await expect(submitPrompt(ctx, agent)).rejects.toThrow('outer failure') @@ -594,7 +604,8 @@ describe('approval policy (the approval/policy fold)', () => { const ctx = new Context() await ctx.plugin(ApprovalService) const { agent, session } = sessionAgent('sess-narr-inherited') - appendHeader(session, ASK_MARKER) + appendToldPolicy(session, 'ask') + appendHeader(session) session.append('approval/policy', { policy: 'never', source: 'delegation' }) await submitPrompt(ctx, agent) @@ -602,11 +613,12 @@ describe('approval policy (the approval/policy fold)', () => { expect(narrations(session)).toEqual(['The approval policy changed from "ask" to "never" (inherited from the delegating session).']) }) - it('narrates a config default drift from the logged ask marker', async () => { + it('narrates a config default drift from retained runtime context', async () => { const ctx = new Context() await ctx.plugin(ApprovalService, { policy: 'never' }) const { agent, session } = sessionAgent('sess-narr-3') - appendHeader(session, `persona only\n${ASK_MARKER}`) + appendToldPolicy(session, 'ask') + appendHeader(session) await submitPrompt(ctx, agent) expect(narrations(session)).toEqual(['The approval policy changed from "ask" to "never" (changed by the operator/config).']) }) @@ -615,40 +627,64 @@ describe('approval policy (the approval/policy fold)', () => { const ctx = new Context() await ctx.plugin(ApprovalService, { policy: 'never' }) const { agent, session } = sessionAgent('sess-narr-4') - appendHeader(session, `persona only\n${ASK_MARKER}`) + appendToldPolicy(session, 'ask') + appendHeader(session) setApprovalPolicy(session, 'ask') - appendHeader(session, `persona only\n${ASK_MARKER}`) + appendHeader(session) await submitPrompt(ctx, agent) expect(narrations(session)).toEqual([]) }) - it('does not infer never from deployment prose that quotes the never sentence', async () => { + it('does not infer never from an unowned message that quotes the never sentence', async () => { const ctx = new Context() await ctx.plugin(ApprovalService) const { agent, session } = sessionAgent('sess-narr-spoof-prose') - appendHeader(session, `persona quotes this warning: ${NEVER_SENTENCE}\n${ASK_MARKER}`) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: NEVER_SENTENCE }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + appendToldPolicy(session, 'ask') + appendHeader(session) await submitPrompt(ctx, agent) expect(narrations(session)).toEqual([]) }) - it('treats a legacy header with no source-owned marker as untold', async () => { + it('treats a legacy header with no owned runtime context as untold', async () => { const ctx = new Context() await ctx.plugin(ApprovalService, { policy: 'never' }) const { agent, session } = sessionAgent('sess-narr-unmarked-header') - appendHeader(session, 'legacy persona-only header') + appendHeader(session) await submitPrompt(ctx, agent) expect(narrations(session)).toEqual([]) }) - it('uses the service marker after an earlier persona marker', async () => { + it('uses the latest owned runtime-context snapshot', async () => { const ctx = new Context() await ctx.plugin(ApprovalService) const { agent, session } = sessionAgent('sess-narr-spoof-marker') - appendHeader(session, `persona quotes ${NEVER_MARKER}\n${ASK_MARKER}`) + appendToldPolicy(session, 'never') + appendToldPolicy(session, 'ask') + appendHeader(session) await submitPrompt(ctx, agent) expect(narrations(session)).toEqual([]) }) + it('does not fall through a newer complete runtime-context snapshot', async () => { + const ctx = new Context() + await ctx.plugin(ApprovalService) + const { agent, session } = sessionAgent('sess-narr-latest-context') + appendToldPolicy(session, 'never') + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Current runtime context:\n\nUnrelated context only.' }], + source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }, + }), { surfaceOp: 'append' }) + appendHeader(session) + + await submitPrompt(ctx, agent) + + expect(narrations(session)).toEqual([]) + }) + it('disposes the service prompt section and pre-step narrator together (HMR safety)', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) @@ -659,12 +695,14 @@ describe('approval policy (the approval/policy fold)', () => { (await ctx.systemPrompt.assemble({ agent: live.agent })).contexts.find(context => context.name === 'approval:policy') expect(await contextFor()).toBeDefined() - appendHeader(live.session, `persona\n${ASK_MARKER}`) + appendToldPolicy(live.session, 'ask') + appendHeader(live.session) setApprovalPolicy(live.session, 'never') await submitPrompt(ctx, live.agent) expect(narrations(live.session)).toEqual(['The approval policy changed from "ask" to "never" (changed by the user).']) - appendHeader(afterDispose.session, `persona\n${ASK_MARKER}`) + appendToldPolicy(afterDispose.session, 'ask') + appendHeader(afterDispose.session) setApprovalPolicy(afterDispose.session, 'never') await fiber.dispose() diff --git a/packages/ui/user-approval/tests/invariant.spec.ts b/packages/ui/user-approval/tests/invariant.spec.ts index 6be086df4a..7266c76927 100644 --- a/packages/ui/user-approval/tests/invariant.spec.ts +++ b/packages/ui/user-approval/tests/invariant.spec.ts @@ -38,7 +38,7 @@ describe('approval invariants', () => { await ctx.plugin(InvariantService) await ctx.plugin(ApprovalInvariant) expect(() => session.append('approval/decided', { id, outcome: 'cancelled' })).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 () => { @@ -77,7 +77,7 @@ describe('approval 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('approval/asked', { id: ApprovalRequestId('ask-replay'), toolName: 'bash', })