diff --git a/AGENTS.md b/AGENTS.md index e7f46dd38a..60763520ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,7 +87,7 @@ printf '%s\n' "$out" | grep -q '\[tool call\] echo({"text":"ci smoke"})' printf '%s\n' "$out" | grep -q '\[tool result\] ECHO: CI SMOKE' test -n "$(find .sessions -path '.sessions/cwd-*/main-session-*.jsonl' -type f -print -quit)" rm -rf .sessions -pnpm exec vitest run --config vitest.e2e.config.ts packages/examples/stdio-demo/tests/built-bin.e2e.ts packages/examples/acp-demo/tests/built-bin.e2e.ts packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts +pnpm exec vitest run --config vitest.e2e.config.ts packages/examples/stdio-demo/tests/built-bin.e2e.ts packages/examples/acp-demo/tests/built-bin.e2e.ts packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts ``` `test:coverage`, not `test`, is the gate ([why](docs/testing.md)); report only commands actually run. diff --git a/docs/architecture.md b/docs/architecture.md index 648318cb67..56e7fee812 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -18,7 +18,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages contribute service | `ctx.tools` | `dsh-tools` | tool registry and [execution pipeline](tool-execution-pipeline.md) | | `ctx.agents` | `dsh-agent` | live agent registry, public `Agent` handle, `agent/*` events | | `ctx.agentExecution` | `dsh-agent-execution` | process-local ambient Agent identity for asynchronous driver work | -| `ctx.agentLoop` | `dsh-agent-loop` | shipped `ReactLoopAgent` driver | +| `ctx.agentLoop` | `dsh-agent-loop` | concrete `Agent` driver | ### Capability Services @@ -57,12 +57,15 @@ Waterfall events behave like around-middleware: a listener delegates by calling The shipped loop drains work from prompt through checkpoint. Every pause is a service call or event available to plugins. -A **session** is one agent's append-only event log. A **turn** drains one queued batch and runs until the model stops asking for tools and no plugin requests continuation. A **step** is one model request plus the tool executions caused by that response. In the flow below ([sequence companion](agent-lifecycle.md)), quoted names are durable session events and event names are extension points. +A **session** is an append-only event log. A **turn** drains queued input until the model stops asking for tools and no plugin requests continuation. A **step** is one model request plus the tool executions caused by that response. In the flow below ([sequence companion](agent-lifecycle.md)), quoted names are durable session events and event names are extension points. + +Startup resolves identity. No id mints `-session-`; `sessionId` resumes or creates; `resumeSessionId` requires history. Active failures emit `agent-loop/config-start-failed(sessionId, error)`, so front doors reject work; teardown stays silent. ### Turn Flow ```text -prepare private session + agent.ctx -> await unpublished setup +choose declarative identity and fresh/resume path + -> prepare private session + agent.ctx -> await unpublished setup -> enter session + agent -> session/created -> agent/created -> enable driving -> agent/session-start(source) -> start driver forever: diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6eb84ea021..7c16d90e4f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -11,7 +11,7 @@ A `Requires:` line lists the service keys the plugin `inject`s: its `cordis.yml` ## `@deepseek-ai/dsh-acp` -Requires: `agents` · `sessions` · `sessionPersistence` · `tools` · `userInteraction` · `llm` · `systemPrompt` +Requires: `agents` · `sessionPersistence` · `tools` · `userInteraction` · `llm` · `systemPrompt` ```ts config-catalog /** Plugin config: the agent template ACP sessions are created from. */ @@ -20,14 +20,14 @@ export interface AcpConfig { provider?: string /** Model name for created agents (must have a registered adapter). */ model?: string - /** Runtime-only transport override for tests; production uses stdio. */ + /** Runtime-only transport override; production uses stdio. */ stream?: Stream } ``` Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/ui/acp/src/index.ts:208`](../packages/ui/acp/src/index.ts) +Source: [`packages/ui/acp/src/index.ts:206`](../packages/ui/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-demo` @@ -71,7 +71,7 @@ export interface Config { Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) -Source: [`packages/examples/acp-demo/src/index.ts:32`](../packages/examples/acp-demo/src/index.ts) +Source: [`packages/examples/acp-demo/src/index.ts:33`](../packages/examples/acp-demo/src/index.ts) ## `@deepseek-ai/dsh-agent-loop` @@ -87,8 +87,10 @@ export interface Config { maxParallelToolCalls?: number /** Agents created or resumed at plugin startup. */ agents: (AgentOptions & { - /** Registry identity for the live agent. */ - id: AgentId + /** Stable config label used in logs and as the fresh combined-id prefix. */ + id: string + /** Optional stable identity; remounts resume its materialized history, while first use creates it fresh. */ + sessionId?: SessionId /** Optional workspace for a fresh session. */ cwd?: string /** Persisted session to resume instead of creating a fresh session. */ @@ -97,9 +99,9 @@ export interface Config { } ``` -Depends on: [`AgentId`](../packages/core/agent/src/index.ts) · [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts) +Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts) -Source: [`packages/core/agent-loop/src/index.ts:335`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:370`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-agent-spine-demo` @@ -394,7 +396,7 @@ export interface DeepSeekCatalogModel { } ``` -Source: [`packages/llm/llm-deepseek/src/index.ts:36`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:33`](../packages/llm/llm-deepseek/src/index.ts) ## `@deepseek-ai/dsh-llm-pi-ai` @@ -597,7 +599,7 @@ export interface Config { } ``` -Source: [`packages/guard/repeat-tool-guard/src/index.ts:27`](../packages/guard/repeat-tool-guard/src/index.ts) +Source: [`packages/guard/repeat-tool-guard/src/index.ts:26`](../packages/guard/repeat-tool-guard/src/index.ts) ## `@deepseek-ai/dsh-sandbox-local` @@ -767,12 +769,12 @@ Requires: `agents` · `userInteraction` export interface Config { /** Banner printed once on start, before the first `> ` prompt. */ welcome?: string - /** Id of the agent stdin drives (`send`/`steer`) and whose status gates the EOF exit; rendering is global. Defaults to `'main'`. */ - agent?: string + /** Exact shared agent/session identity stdin drives. Defaults to `'main'`. */ + sessionId?: string } ``` -Source: [`packages/ui/stdio/src/index.ts:30`](../packages/ui/stdio/src/index.ts) +Source: [`packages/ui/stdio/src/index.ts:33`](../packages/ui/stdio/src/index.ts) ## `@deepseek-ai/dsh-stdio-demo` @@ -813,7 +815,7 @@ export interface Config { /** Generic background-task control-tool config forwarded through agent-core. */ toolTasks?: NonNullable /** - * If set, the `main` agent RESUMES this persisted session id instead of + * If set, the pre-created agent RESUMES this persisted session id instead of * starting fresh. Sourced from an env var in the leaf `cordis.yml` * (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`). */ @@ -825,7 +827,7 @@ export interface Config { Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) -Source: [`packages/examples/stdio-demo/src/index.ts:37`](../packages/examples/stdio-demo/src/index.ts) +Source: [`packages/examples/stdio-demo/src/index.ts:40`](../packages/examples/stdio-demo/src/index.ts) ## `@deepseek-ai/dsh-subagent-acp` @@ -921,7 +923,7 @@ export interface Config { Depends on: [`SubagentCapabilities`](../packages/subagent/subagent/src/index.ts) · [`SubagentStopReason`](../packages/subagent/subagent/src/index.ts) -Source: [`packages/support/subagent-mock/src/index.ts:86`](../packages/support/subagent-mock/src/index.ts) +Source: [`packages/support/subagent-mock/src/index.ts:87`](../packages/support/subagent-mock/src/index.ts) ## `@deepseek-ai/dsh-subagent-spawn` diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index 207ea114e0..b44b0e51b4 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -extension-cookbook.md: 3474bc116b43f9be57b52e947f9cf99730f7e796 -extension-cookbook.zh.md: 1a605b20fe4e171a948ac2a046193a2f4d884e44 +extension-cookbook.md: 995ecd879faec02044b55ce3d53f7fe1b2aed270 +extension-cookbook.zh.md: e3fe3103e13c359e77e0202df223aae35bcdd525 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 3474bc116b..995ecd879f 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -40,7 +40,7 @@ A UI plugin renders from the `session/event` feed (the assistant token stream as ```ts import type { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' declare function render(text: string): void declare function onUserInput(handler: (text: string) => void): void @@ -54,7 +54,7 @@ export function apply(ctx: Context) { render(event.data.chunk.text) } }) - onUserInput(text => ctx.agents.get(AgentId('main'))?.send([{ type: 'text', text }])) + onUserInput(text => ctx.agents.get(SessionId('client-session'))?.send([{ type: 'text', text }])) } ``` diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 1a605b20fe..e3fe3103e1 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -40,7 +40,7 @@ UI 插件从 `session/event` 事件流渲染(助手 token 流以 `assistant/ch ```ts import type { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' declare function render(text: string): void declare function onUserInput(handler: (text: string) => void): void @@ -54,7 +54,7 @@ export function apply(ctx: Context) { render(event.data.chunk.text) } }) - onUserInput(text => ctx.agents.get(AgentId('main'))?.send([{ type: 'text', text }])) + onUserInput(text => ctx.agents.get(SessionId('client-session'))?.send([{ type: 'text', text }])) } ``` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 6fbffc1798..4a19b57af5 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -23,7 +23,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:154`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:141`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -35,7 +35,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:150`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -47,7 +47,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:298`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:285`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -59,7 +59,7 @@ Awaited serial checkpoint for session-surface mutation after prompt assembly and Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:217`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:204`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -71,7 +71,7 @@ Allow, rewrite, or block one drained prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:227`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -83,7 +83,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:182`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:169`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -95,7 +95,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:239`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:226`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -107,7 +107,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:254`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:241`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -119,7 +119,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:195`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:182`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -131,7 +131,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:172`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:159`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -143,7 +143,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:265`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:252`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -155,7 +155,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:275`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:262`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -167,7 +167,19 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:285`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:272`](../../packages/core/agent/src/types.ts) + +## `agent-loop/*` + +### `agent-loop/config-start-failed` — emit + +A declarative agent entry failed before it could publish a live agent. Consumers that buffer work for the configured identity use this transient signal to reject that work instead of waiting forever. Normal factory teardown suppresses failures from the cancelled startup attempt. + +```ts cordis-catalog +'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void +``` + +Source: [`packages/core/agent-loop/src/index.ts:363`](../../packages/core/agent-loop/src/index.ts) ## `approval/*` @@ -289,7 +301,7 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c 'subagent/end'(this: Scoped, info: SubagentRunEndInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:108`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:112`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit @@ -299,7 +311,7 @@ A provider became resolvable in the registry. 'subagent/provider-added'(provider: SubagentProvider): void ``` -Source: [`packages/subagent/subagent/src/index.ts:82`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:86`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-removed` — emit @@ -309,7 +321,7 @@ A provider left the registry. Accepted runs remain holder-owned. 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:88`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:92`](../../packages/subagent/subagent/src/index.ts) ### `subagent/start` — emit @@ -319,7 +331,7 @@ A provider established a ready child. For in-process providers, `ctx.agents.get( 'subagent/start'(this: Scoped, info: SubagentRunInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:99`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:103`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 2e64a93a8f..99107c061a 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -25,15 +25,17 @@ Source: [`packages/core/agent-execution/src/index.ts:18`](../../packages/core/ag ## `ctx.agentLoop` — `AgentLoop` -Concrete ReactLoopAgent factory and driver service. +Concrete agent factory and driver service. ```ts cordis-catalog -create(id: AgentId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent +create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): Agent async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:353`](../../packages/core/agent-loop/src/index.ts) +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/core/agent-loop/src/index.ts:408`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry` @@ -44,15 +46,17 @@ setFactory(factory: AgentFactory): () => void async create(options: CreateAgentOptions): Promise async resume(options: ResumeAgentOptions): Promise register(agent: Agent): () => void -enter(agent: Agent): () => void +enter(agent: Agent, owner: Agent | undefined): () => void announce(agent: Agent): void -get(id: AgentId): Agent | undefined +get(id: SessionId): Agent | undefined +isOwnedBy(id: SessionId, owner: Agent): boolean list(): Agent[] +roots(): Agent[] ``` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:133`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:201`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` @@ -119,7 +123,7 @@ Abstract compaction service. Implementations own trigger policy, retention, and ```ts cordis-catalog abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal, ): Promise -abstract compactRegion( session: Session, start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise +abstract compactRegion( start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise ``` Types: [Message](../core-data-structures/core.md) @@ -158,7 +162,7 @@ stream(options: GenerateOptions): AsyncIterable Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:96`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:94`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` @@ -234,7 +238,7 @@ list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Source: [`packages/core/session/src/index.ts:580`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:585`](../../packages/core/session/src/index.ts) ## `ctx.skills` — `SkillService` @@ -276,7 +280,7 @@ list(): string[] async start(name: string, request: SubagentStartRequest): Promise ``` -Source: [`packages/subagent/subagent/src/index.ts:141`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:153`](../../packages/subagent/subagent/src/index.ts) ## `ctx.systemPrompt` — `SystemPrompt` diff --git a/docs/core-data-structures/approval.md b/docs/core-data-structures/approval.md index c5fa1fe13b..e8820ccfeb 100644 --- a/docs/core-data-structures/approval.md +++ b/docs/core-data-structures/approval.md @@ -6,7 +6,7 @@ Source: [`packages/ui/user-approval/src/index.ts`](../../packages/ui/user-approv ## Identity and outcome -Every request receives a fresh `ApprovalRequestId`. The brand pairs the `approval/asked` and `approval/decided` audit events without making approval ids interchangeable with tool-call, session, or agent ids. +Every request receives a fresh `ApprovalRequestId`. The brand pairs the `approval/asked` and `approval/decided` audit events without making approval ids interchangeable with tool-call or agent/session ids. ```ts type-equiv type ApprovalRequestId = Branded<'ApprovalRequestId'> diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index 45a0ac310e..11f895b2a8 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -1,6 +1,6 @@ # Compaction -The compaction seam — a [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) split like bash: interface ([dsh-compact](../../packages/compact/compact), `ctx.compact`), implementation (a backend such as [dsh-compact-basic](../../packages/compact/compact-basic)), and consumer (a `/compact` tool, deferred). Compaction is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A tokenizer- or template-based backend is a sibling package implementing the same interface. Unlike bash, the interface necessarily depends on `dsh-session` and `dsh-llm`: its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary (see the [compaction capability-seam RFC](../rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)). +The compaction seam — a [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) split like bash: interface ([dsh-compact](../../packages/compact/compact), `ctx.compact`), implementation (a backend such as [dsh-compact-basic](../../packages/compact/compact-basic)), and consumer (a `/compact` tool, deferred). Compaction is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A tokenizer- or template-based backend is a sibling package implementing the same interface. Unlike bash, the interface necessarily depends on `dsh-session` and `dsh-llm`: its verbs act on an agent-owned `Session`, and its durable summary event uses the `ContentBlock` vocabulary (see the [compaction capability-seam RFC](../rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)). Source: [`packages/compact/compact/src/types.ts`](../../packages/compact/compact/src/types.ts) @@ -20,7 +20,7 @@ These variants are merged inside a `declare module '@deepseek-ai/dsh-session'` b ## `CompactionResult` -What a successful compaction returns to its caller: the seqs of the three appended `compact/*` events, the summary blocks, and the shadowed range/seqs plus the estimated token count. +What a successful compaction returns to its caller: the bookkeeping-event seqs, raw summary, shadowed range and seqs, and estimated token count. ```ts type-equiv interface CompactionResult { diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 1574db2d06..74dfa1ce78 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -76,7 +76,7 @@ Two large discriminated unions are the ones consumers `switch` over most: **`Str ## Branded IDs -IDs that cross package boundaries are **branded** — structurally strings, but non-interchangeable at the type level (an `AgentId` can't be passed where a `CallId` is expected). Construction goes through a per-type factory; comparison, logging, and JSON behave as ordinary strings. +IDs that cross package boundaries are **branded** — structurally strings, but non-interchangeable at the type level (a `SessionId` cannot be passed where a `CallId` is expected). Construction goes through a per-type factory; comparison, logging, and JSON behave as ordinary strings. The `Branded` primitive lives in its own type-only package, [dsh-brand](../../packages/util/brand) (no runtime code, no harness-package dependency), so any package can brand the ids it owns without depending on an unrelated capability package. @@ -86,7 +86,7 @@ Source: [`packages/util/brand/src/index.ts`](../../packages/util/brand/src/index type Branded = string & { readonly [BRAND]: B } ``` -The three core IDs are `CallId`, `SessionId`, and `AgentId`. Capability packages brand their own ids too, such as `TaskId` in [tasks.md](tasks.md). +The two core IDs are `CallId` (correlates a tool call with its result; dsh-llm) and `SessionId` (the shared live agent and durable session identity; dsh-session). Capability packages brand their own ids too, such as `TaskId` in [tasks.md](tasks.md). ## Content blocks and messages @@ -288,7 +288,7 @@ The fourteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, ## The agent handle -`Agent` is the surface every plugin (UI, hooks, orchestrators) programs against. The concrete implementation is `ReactLoopAgent` in dsh-agent-loop; nothing outside the loop depends on the implementation. +`Agent` is the surface every plugin (UI, hooks, orchestrators) programs against. The concrete implementation is package-internal to dsh-agent-loop; nothing outside the loop depends on it. Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) @@ -303,7 +303,7 @@ interface InjectOptions extends SendOptions { ```ts type-equiv interface Agent { - readonly id: AgentId + readonly id: SessionId readonly options: AgentOptions readonly session: Session readonly status: AgentStatus @@ -409,7 +409,7 @@ interface Agent { } ``` -`AgentStatus` is `'idle' | 'running' | 'disposed'`, and `AgentId` is branded. `AgentOptions` is merge-extensible and currently includes `provider?` and `model?`; dispatch requires both after `agent/request`. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. +`AgentStatus` is `'idle' | 'running' | 'disposed'`, and `SessionId` is branded. `AgentOptions` is merge-extensible and currently includes `provider?` and `model?`; dispatch requires both after `agent/request`. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. The [event taxonomy](../architecture.md#event) owns the `agent/*` lifecycle, checkpoint, and waterfall contracts. Turn and step boundaries are durable session events rather than agent emits. diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 28093165bd..815438f215 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -68,7 +68,8 @@ interface SubagentStopReasonMap { ```ts type-equiv interface SubagentRun { - readonly id: AgentId + readonly id: SessionId + readonly localAgent: Agent | undefined readonly result: Promise dispose(): Promise sendMessage?(content: ContentBlock[]): void @@ -76,6 +77,8 @@ interface SubagentRun { } ``` +A local run MUST publish an ordinary child agent/session before `start()` fulfills, return that child session id as `SubagentRun.id`, expose the exact child as `localAgent`, and record `request.parent.session.id` in the child's `parentSession` header. Runtime ownership may place the child under the parent, provider, or root scope. A remote provider instead returns a parent-scoped lifecycle id and `localAgent: undefined`. + ## The provider seam: `SubagentProvider` Each provider is a named child-agent transport, and multiple providers may coexist. The service validates requested start-time capabilities before `start()`. `inheritsParentContext` describes only conversation seeding (`fork`: true; `spawn` and `acp`: false), allowing consumers to generate accurate model-facing wording without implying inherited tools, services, or authority. @@ -89,7 +92,7 @@ interface SubagentProvider { } ``` -`start()` fulfills only with a ready run. The service observes its result, emits `subagent/start`, and returns the same run; rejection implies provider cleanup and emits no lifecycle pair. In-process children are discoverable through `ctx.agents`, while remote children need not be. `subagent/end` reports final output or infrastructure failure. Both events are observe-only and contain listener exceptions. +`start()` fulfills only with a ready run. The service mints a unique `runId`, snapshots `local` from the provider's exact `localAgent`, observes the result, emits `subagent/start`, and returns the same run; rejection implies provider cleanup and emits no lifecycle pair. The paired `subagent/end` carries the same identity and the final output or infrastructure failure. Both events are observe-only and contain listener exceptions. ## In-process backends: depth and seed diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index acd90d4423..cfa5160f41 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,32 +7,33 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:154`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio`](../packages/ui/stdio) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:163`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:217`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:227`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:182`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:239`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:254`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:195`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:172`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio`](../packages/ui/stdio) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:265`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:275`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:363`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio`](../packages/ui/stdio) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:141`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:150`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:204`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:214`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:169`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:226`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:241`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:182`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:159`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:252`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:262`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:272`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:53`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:40`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:46`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:68`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`token-meter`](../packages/llm/token-meter), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:78`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:108`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:82`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:88`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:99`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:112`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:86`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:92`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:103`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:27`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`acp`](../packages/ui/acp) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:33`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:116`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | diff --git a/docs/module-graph.md b/docs/module-graph.md index 2d3a3a0e4a..83ccdd64c3 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -232,6 +232,7 @@ flowchart TD pkg_workflow --> pkg_agent pkg_workflow --> pkg_brand pkg_workflow --> pkg_llm + pkg_workflow --> pkg_session pkg_tools --> pkg_agent pkg_tools --> pkg_code_runtime pkg_tools --> pkg_llm @@ -246,10 +247,6 @@ flowchart TD pkg_permission --> pkg_sandbox pkg_permission --> pkg_session pkg_permission --> pkg_user_approval - pkg_stdio --> pkg_agent - pkg_stdio --> pkg_llm - pkg_stdio --> pkg_session - pkg_stdio --> pkg_user_interaction pkg_agent_loop --> pkg_agent pkg_agent_loop --> pkg_agent_execution pkg_agent_loop --> pkg_llm @@ -285,8 +282,10 @@ flowchart TD pkg_tool_skill --> pkg_skill pkg_tool_skill --> pkg_tools pkg_subagent --> pkg_agent + pkg_subagent --> pkg_brand pkg_subagent --> pkg_llm pkg_subagent --> pkg_scope + pkg_subagent --> pkg_session pkg_subagent --> pkg_tools pkg_tool_web --> pkg_llm pkg_tool_web --> pkg_system_prompt @@ -352,6 +351,7 @@ flowchart TD pkg_tool_workflow --> pkg_workflow pkg_subagent_acp --> pkg_agent pkg_subagent_acp --> pkg_llm + pkg_subagent_acp --> pkg_session pkg_subagent_acp --> pkg_subagent pkg_subagent_acp --> pkg_subagent_subprocess pkg_subagent_inprocess --> pkg_agent @@ -374,12 +374,19 @@ flowchart TD pkg_hooks_claude --> pkg_tools pkg_subagent_mock --> pkg_agent pkg_subagent_mock --> pkg_llm + pkg_subagent_mock --> pkg_session pkg_subagent_mock --> pkg_subagent pkg_jsonrpc --> pkg_agent pkg_jsonrpc --> pkg_llm pkg_jsonrpc --> pkg_llm_deepseek + pkg_jsonrpc --> pkg_scope pkg_jsonrpc --> pkg_session pkg_jsonrpc --> pkg_subagent + pkg_stdio --> pkg_agent + pkg_stdio --> pkg_agent_loop + pkg_stdio --> pkg_llm + pkg_stdio --> pkg_session + pkg_stdio --> pkg_user_interaction pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_execution pkg_agent_spine_demo --> pkg_agent_loop @@ -417,6 +424,7 @@ flowchart TD pkg_acp_demo --> pkg_user_interaction pkg_acp_demo --> pkg_workspace_context pkg_stdio_demo --> pkg_agent + pkg_stdio_demo --> pkg_agent_loop pkg_stdio_demo --> pkg_agent_spine_demo pkg_stdio_demo --> pkg_app_boot pkg_stdio_demo --> pkg_llm @@ -483,17 +491,16 @@ flowchart TD | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent) | | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | +| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | -| [`stdio`](../packages/ui/stdio) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-interaction`](../packages/ui/user-interaction) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`agent-execution`](../packages/core/agent-execution), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`home`](../packages/util/home), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | @@ -508,15 +515,16 @@ flowchart TD | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) | | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | +| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`subagent-mock`](../packages/support/subagent-mock) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) | -| [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | +| [`subagent-mock`](../packages/support/subagent-mock) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | +| [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | +| [`stdio`](../packages/ui/stdio) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-interaction`](../packages/ui/user-interaction) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-execution`](../packages/core/agent-execution), [`agent-loop`](../packages/core/agent-loop), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | -| [`stdio-demo`](../packages/examples/stdio-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`stdio`](../packages/ui/stdio), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | +| [`stdio-demo`](../packages/examples/stdio-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`stdio`](../packages/ui/stdio), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 91d6be4f74..34869b4fd3 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -20,8 +20,10 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | Title | First proposed | |---|---| -| [Unify the agent id and the session id](proposed/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 | | [Prune dead public and result surface](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 | +| [Make JSON-RPC completion and transport directional](proposed/simplification/2026-07-19-make-jsonrpc-directional.md) | 2026-07-19 | +| [Retire the standalone subagent mock package](proposed/simplification/2026-07-19-retire-subagent-mock-package.md) | 2026-07-19 | +| [Use one surface manager per session](proposed/simplification/2026-07-19-use-one-session-surface-manager.md) | 2026-07-19 | ### Architecture @@ -99,6 +101,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Prune dead methods from the persistence seam](implemented/simplification/2026-06-20-prune-dead-seam-methods.md) | 2026-06-20 | | [Keep one public stop primitive](implemented/simplification/2026-06-20-public-agent-stop-surface.md) | 2026-06-20 | | [Stop mirroring durable boundaries as agent events](implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 | +| [Unify the agent id and the session id](implemented/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 | | [Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin](implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) | 2026-06-26 | | [Stop mirroring the token stream as an agent event](implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md) | 2026-07-02 | | [Drop the `image` content block until a path can honor it](implemented/simplification/2026-07-04-drop-image-content-block.md) | 2026-07-04 | @@ -231,6 +234,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Prune the unimplemented subagent seam vocabulary](rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md) | 2026-07-04 | | [Collapse workflows to the exercised foreground core](rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md) | 2026-07-12 | | [Prune unused skill registry surface](rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.md) | 2026-07-12 | +| [Fold the single compaction backend into its service package](rejected/simplification/2026-07-19-fold-compaction-package-split.md) | 2026-07-19 | ### Architecture diff --git a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md index 1c22f9b7ca..77f521834c 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md +++ b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md @@ -23,4 +23,4 @@ In-session context injection (`context/message`, `steering/message`) renders as - Multimodal blocks return only with coordinated adapter, UI, and compaction support; see [the drop-image RFC](../simplification/2026-07-04-drop-image-content-block.md). - Cache hints and assistant prefill remain absent until a shipping adapter can honor them; see the [producer-less variants](../simplification/2026-07-04-prune-producerless-vocabulary-variants.md) and [inert request knobs](../simplification/2026-07-04-drop-inert-request-knobs.md) RFCs. - Every adapter pays a translation cost; the first real adapters have since validated the streaming protocol, and new adapters should continue proving their provider-specific mapping in adapter-local tests. -- IDs that cross package boundaries are branded (`CallId`, `SessionId`, `AgentId`) — nominal typing at zero runtime cost. +- IDs that cross package boundaries are branded (`CallId`, the shared agent/session `SessionId`) — nominal typing at zero runtime cost. diff --git a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md index 327aa8eac3..3bd3c3fdf1 100644 --- a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md +++ b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md @@ -21,7 +21,7 @@ Key choices recorded here because they are durable, contested, and surprising: - **Append-only; a crashed turn is closed, never truncated.** Events through a flushed `turn/end` are never rewritten, and the loop flushes only at turn end. Because one interrupted turn may contain substantial valid work, `load` preserves its contiguous, parseable events and appends error results for unanswered tool calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }`. The synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded; a parse error or sequence gap at or before the last real `turn/end` is corruption and makes the session unloadable. - **File backend canonical, DB backend a proven drop-in.** `SessionEvent` maps 1:1 onto a row `(session_id, seq, type, time, data)` — `append` is INSERT (in a transaction asserting the contiguous-seq contract), `load` is SELECT … ORDER BY seq. `dsh-session-persistence-sqlite` is exactly this: a `SessionPersistence` subclass with no interface change (opencode runs this exact shape on SQLite/WAL), and it passes the same `runPersistenceContract` suite as the JSONL backend — so the contract holds both backends to identical semantics (lazy materialization, interrupted-turn close on load, contiguous-seq), expressed once over file bytes and once over rows. - **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header seam is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../simplification/2026-06-19-drop-mutable-session-summary.md).) -- **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` awaits `ctx.sessionPersistence.load`, recreates the live session with the loaded events (so `lastTurnNumber`/`deriveMessages` continue), and starts a fresh agent on the resumed id (NOT `${agentId}-session`). The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent. +- **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` awaits `ctx.sessionPersistence.load`, recreates the live session with the loaded events (so `lastTurnNumber`/`deriveMessages` continue), and registers the fresh agent under the exact resumed id. The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent. ## Alternatives considered diff --git a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md index 665063399d..d75ec379fc 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md +++ b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md @@ -12,28 +12,30 @@ Three seams: the queue-aware cancel, the `AgentHandle` disposer, and the bash ow ### 1. Queue-aware `Agent.cancel(reason?)` -`cancel()` is the single public stop primitive. It clears queued and steering input, aborts an in-flight step, and arms a turn-scoped marker checked at each turn boundary. A queued prompt therefore cannot start after cancellation or absorb later input. `whenIdle()` waits for post-cancel quiescence, and ACP `session/cancel` maps to this method. An idle cancel does not arm the marker. +A new `cancel()` verb on the `Agent` interface — the single public stop primitive. (It originally shipped alongside a narrower step-only `abort()`; that verb was later removed as unused, leaving `cancel()` the only public way to stop work.) It clears the inbox's queued + steering FIFOs, aborts the in-flight step if any, and drives a **turn-scoped cancellation marker** the driver loop checks at every turn-decision point — so a prompt that is queued-but-not-yet-started never runs, a cancel landing in the pre-step / continuation window drops the about-to-run turn (ending it `aborted`), and a later prompt cannot be batched into the cancelled turn. `whenIdle()` reaches post-cancel quiescence. ACP `session/cancel` maps to `cancel()`. The marker is armed ONLY when there is something to cancel, so an idle no-op cancel cannot strand the next prompt. ### 2. `AgentHandle` async disposer -`ctx.agents.create`/`resume` and `AgentFactory` return `AgentHandle = { agent, dispose() }`. Disposal is a consumer capability; an observer holding only `Agent` cannot tear it down. The caller fiber and factory provider also own the instance, and every path shares one memoized teardown: stop the loop, await quiescence and flushes, detach the agent and session, then unwind its scope. IDs become reusable when their registry entries detach. Config-created agents belong to the loop fiber; ACP stores and disposes each session handle. +`ctx.agents.create`/`resume` (and the `AgentFactory` interface) return `AgentHandle = { agent: Agent; dispose(): Promise }`. The disposer is a **consumer capability** — a registry observer holding only the bare `Agent` cannot tear it down. The caller fiber and registered factory provider are structural co-owners: caller unload enforces structured ownership, while provider unload must stop old instances whose scoped dependency surface resolves through that provider. All three paths reach the same memoized teardown: stop the loop, await its exit and idle flushes (true quiescence, not just the `disposed` status flip), detach the agent, detach its session, and unwind its scope. Each public ID becomes reusable when its exact registry entry detaches; there is no separate reservation-release phase. Config-created agents are already owned by the `AgentLoop` fiber (the handle is discarded). ACP holds each session's disposer in its `SessionRecord` and runs it on disconnect/teardown, so a bare client disconnect leaves no registered agent and no session-store entry — even when `session/load` races teardown (the just-resumed handle is disposed before the closed-guard throw). -Teardown order is load-bearing for durability. The session lifecycle and loop share one composite Cordis effect so LIFO disposal stops the loop and awaits `agent.done` before detaching the session. Sibling effects would dispose concurrently and could remove append hooks before the closing flush. Disposal notifications are contained so they cannot interrupt the chain. +**Teardown ORDER is load-bearing for durability**, and the implementation folds the session lifecycle into the agent's SINGLE composite cordis effect (`SessionStore.prepare`/`enter`/`announce`, replacing a sibling-effect split). A fiber unload disposes sibling effects concurrently (`Promise.all`), which would race removing the session store's append publication hooks against the loop's closing `session/flush` and drop the closing `turn/end`; inside one effect the disposers run as an ordered LIFO chain (loop stopped + `await agent.done` BEFORE the session detaches), so the loop's final flush is captured on BOTH the handle's `dispose()` and a fiber unload. The contained `agent/disposed` and `session/disposed` notifications cannot reject the chain or skip later teardown. ### 3. Bash owner token in the seam -Background task ownership belongs to the executor. `BashExecSpec.owner` carries an optional opaque token, `ownerOf(id)` reads it, and `dsh-tool-bash` stamps the calling session token at start. `bash_output` and `bash_kill` reject mismatched callers; completion notices locate the live agent by session token through the registry. Keeping ownership on the task preserves the fence across tool-plugin reloads. The completion listener remains effect-scoped, so a notice that settles during the reload gap may still be dropped. +Background-task ownership moved from a `tool-bash` plugin-local `Map` into the executor. `BashExecRequest` gains an optional `owner?: string`; the resolved `BashExecSpec` carries it as required-but-nullable `owner: string | undefined` (a forgotten owner is a visible `undefined`, never a silently-absent property). The executor stores the token on its task and exposes it via a new `BashExecutor.ownerOf(id): string | undefined` seam (NOT on the public `BashTask` — one read path, no redundant API). `tool-bash` deletes its `Map` entirely: it stamps `exec.agent?.id` (the shared registry/session id) as the owner at `start`, and `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token with `!== undefined` semantics (an empty-string token is still a real owner). The completion notice finds the live agent by scanning `ctx.get('agents')?.list()` for `agent.id === ownerToken` (read via `ctx.get` — `onTaskDone` runs on the bash fiber, a foreign fiber, where the `ctx.agents` proxy would throw). Because ownership now lives on the task in the executor (disposed with the `dsh-bash` fiber), it SURVIVES a `tool-bash` HMR reload — closing the old `XXX(tool-bash-owner-hmr)` gap. (The `onTaskDone` listener is still effect-scoped to `tool-bash`'s `apply`, so a completion landing during the reload gap still drops its one notice — the pre-existing reload-gap drop — but the ownership fence itself is HMR-proof.) ## Verification -- ACP disconnect or session close leaves no registered agent or session-store entry, including when `session/load` races teardown. -- Cancelling before a queued prompt starts prevents that prompt from running or absorbing the next prompt. -- Reloading `dsh-tool-bash` does not let another session read or kill an existing background task because ownership remains on the executor. -- Config-created agents remain loop-fiber-owned, so non-ACP demos need not manage handles explicitly. +These invariants hold and are pinned by tests: + +- ACP disconnect/session close leaves no registered agent AND no session-store entry for that session, even when `session/load` races teardown. +- `session/cancel` before a queued prompt starts prevents that prompt from running and cannot batch the next prompt into the cancelled turn. +- A `tool-bash` HMR reload does NOT make an existing background task readable or killable by a different session (ownership survives on the executor). +- Existing non-ACP demos still work without managing handles explicitly; config-created agents remain owned by the `AgentLoop` plugin fiber. ## Session owner tokens are unique among live agents -The bash owner token relies on `session.header.id` being unique among live agents. Concurrent same-ID operations may prepare privately, but `SessionStore.enter()` rejects duplicate publication and the losing transaction rolls back. `tool-bash` owns the comparison policy; the bash seam stores an opaque `owner` string without interpreting it. +The bash owner-token comparison relies on the shared `Agent.id`/`SessionId` being unique among live agents. Concurrent same-ID operations may both prepare privately, but publication enters the session and agent in order; `SessionStore.enter()` rejects a duplicate live session id, and every losing transaction rolls its private state back. A programmatic caller therefore cannot publish two live agents with one session token. The access *policy* (token comparison) stays in `tool-bash` (the consumer); the bash seam stores only an opaque `owner` string and never interprets it — the correct interface/implementation/consumer split. ## Alternatives considered diff --git a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md index 9f83e46b24..fd5c5bf953 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md +++ b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md @@ -4,23 +4,23 @@ Status: implemented ## Problem -The harness already brands three identifiers — `CallId` (`packages/llm/llm/src/brand.ts`), `SessionId` (`packages/core/session/src/types.ts`), and `AgentId` (`packages/core/agent/src/types.ts`) — using the `Branded = string & { readonly [BRAND]: B }` machinery (owned by the type-only `@deepseek-ai/dsh-brand` package at `packages/util/brand/` — see its [README](../../../../packages/util/brand/README.md)) and a zero-cost cast factory per type. `dsh-brand` also states the governing policy: *"Branding is for ids that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker today. +The harness brands `CallId` (`packages/llm/llm/src/brand.ts`) and the shared agent/session `SessionId` (`packages/core/session/src/types.ts`) using the `Branded = string & { readonly [BRAND]: B }` machinery (owned by the type-only `@deepseek-ai/dsh-brand` package at `packages/util/brand/` — see its [README](../../../../packages/util/brand/README.md)) and a zero-cost cast factory per type. `dsh-brand` also states the governing policy: *"Branding is for ids that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker today. -**Gap 1 — unbranded IDs in the bash seam.** `BashTask.id` and every executor/tool boundary used bare `string`, even though the generated value has the same `name-N` shape as default session ids. The model also returns this value through `task_id`, so confusing task and session ids was both type-correct and reachable. +**Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-task id is a plain `string`: `BashTask.id: string` (`packages/bash/bash/src/types.ts`), carried as `string` through the whole executor seam (`BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/bash/bash/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateTaskId`, `assertTaskAccess`, the `task_id` schema arg in `packages/bash/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/bash/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/core/session/src/index.ts`). A bash task id and a session id are trivially swappable at a call site and the compiler says nothing. This is the headline case the user asked about, and it is a model-facing id (the model passes `task_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input. -The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` (`packages/bash/bash/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's `session.header.id` (`callerToken = (exec) => exec.agent?.session.header.id` in `packages/bash/tool-bash/src/index.ts`) — i.e. a `SessionId` wearing a `string` disguise. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the same `session.header.id`-as-owner alias that the [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) proposal calls the "bash owner-token alias hole". +The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` (`packages/bash/bash/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's shared `Agent.id`/`SessionId` (`callerToken = (exec) => exec.agent?.id` in `packages/bash/tool-bash/src/index.ts`) wearing a different seam-local name. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the shared id alias covered by the [unified agent/session identity decision](../simplification/2026-06-20-unify-agent-and-session-id.md). -**Gap 2 — erosion of existing brands.** `CallId`, `SessionId`, and `AgentId` became bare strings in registry maps, public lookup parameters, ACP session tracking, and the persistence coordinator. Dropping a brand at a lookup boundary defeats its main protection. +**Gap 2 — brand erosion at the seams of the *already-branded* IDs.** Even `CallId` and `SessionId` decay back to bare `string` at exactly the places confusion is most likely: registry/store key types and public method params. Representative sites include the session store, the agent registry (both keyed by the shared `SessionId`), `ToolPresenter`'s call-id map, ACP's session-id records and loading set, and the persistence coordinator. A brand that is dropped at a collection key buys nothing on lookups — the value of the existing brands is partly unrealized. ## Decision A type-only change. Brands are zero-cost casts; nothing about runtime behavior, serialization, comparison, or the wire format changes. The work is in three parts, all honoring the existing "not every string" policy. -- **Brand the bash task id.** Add `BashTaskId = Branded<'BashTaskId'>` plus its same-named factory in `packages/bash/bash/src/types.ts` (the package that *owns* the id), importing `Branded` from `@deepseek-ai/dsh-brand` exactly as `SessionId`/`AgentId` already do. The brand primitive lives in the dependency-free `dsh-brand` utility package precisely so `dsh-bash` can brand its ids by depending on it alone — it never pulls in `dsh-llm` (or `dsh-session`) just to reach `Branded`. Thread it through `BashTask.id`, the `BashExecutor` seam methods (`get`/`ownerOf`/`readOutput`/`kill`), the generation site in `dsh-bash-local` (brand the counter output once, at creation), and the `dsh-tool-bash` validate/access surface (`validateTaskId` returns a `BashTaskId`; `task_id` is branded at the tool boundary where the model's string arrives). +- **Brand the bash task id.** Add `BashTaskId = Branded<'BashTaskId'>` plus its same-named factory in `packages/bash/bash/src/types.ts` (the package that *owns* the id), importing `Branded` from `@deepseek-ai/dsh-brand` exactly as `SessionId` does. The brand primitive lives in the dependency-free `dsh-brand` utility package precisely so `dsh-bash` can brand its ids by depending on it alone — it never pulls in `dsh-llm` (or `dsh-session`) just to reach `Branded`. Thread it through `BashTask.id`, the `BashExecutor` seam methods (`get`/`ownerOf`/`readOutput`/`kill`), the generation site in `dsh-bash-local` (brand the counter output once, at creation), and the `dsh-tool-bash` validate/access surface (`validateTaskId` returns a `BashTaskId`; `task_id` is branded at the tool boundary where the model's string arrives). -- **Mint a distinct `OwnerToken` brand.** Add `OwnerToken = Branded<'OwnerToken'>` in `packages/bash/bash/src/types.ts`; type `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` as `OwnerToken | undefined`. The `dsh-tool-bash` consumer casts the agent's `session.header.id` (a `SessionId`) into an `OwnerToken` at the boundary — the one place the two vocabularies meet. The bash seam never imports `dsh-session`. (Rationale in the next section.) +- **Mint a distinct `OwnerToken` brand.** Add `OwnerToken = Branded<'OwnerToken'>` in `packages/bash/bash/src/types.ts`; type `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` as `OwnerToken | undefined`. The `dsh-tool-bash` consumer casts the agent's shared `id` (`SessionId`) into an `OwnerToken` at the boundary — the one place the two vocabularies meet. The bash seam never imports `dsh-session`. (Rationale in the next section.) -- **Stop the brand erosion.** Propagate the existing brands to the `Map` key types and public method params listed under Gap 2 — `Map`, `get(id: SessionId)`, `Map`, `Map`, the ACP `SessionRecord.sessionId: SessionId` surface, the coordinator's `Map`. This is the larger mechanical share of the diff and the part that makes the *existing* brands actually load-bearing on lookups, not just on the struct fields. +- **Stop the brand erosion.** Propagate the existing brands to the `Map` key types and public method params listed under Gap 2 — `Map`, `Map`, `get(id: SessionId)`, `Map`, ACP's `SessionId` surface, and the coordinator's `Map`. This is the larger mechanical share of the diff and the part that makes the *existing* brands actually load-bearing on lookups, not just on struct fields. Illustrative shape (the factory pattern is identical to the three existing brands): @@ -44,7 +44,7 @@ export function OwnerToken(id: string): OwnerToken { ### Why not typing `owner` as `SessionId`? -The executor treats ownership as opaque and must not depend on the session model. A distinct `OwnerToken` preserves that boundary while preventing raw strings or task ids from being passed as owners. `dsh-tool-bash`, which owns the access policy, performs the single conversion from `SessionId`. +The obvious shortcut is to type `owner` as `SessionId` directly — it always *is* one. We reject that. The bash executor seam is a capability seam (interface `dsh-bash`, implementation `dsh-bash-local`, consumer `dsh-tool-bash`) and its owner token is *documented as deliberately opaque*: the executor "never interprets it (no access policy lives in the seam — that is the consumer's job)" (`packages/bash/bash/src/types.ts`). Typing the seam's field as `SessionId` would import `dsh-session`'s vocabulary into a package that must not know what an owner token *means* — it would couple a generic execution backend to the session model and contradict the opaque-token design. A sandboxed or remote executor that replaces `dsh-bash-local` should not inherit a session dependency. The distinct `OwnerToken` brand keeps the seam decoupled: `dsh-bash` knows only "an owner is some opaque branded token," and the `dsh-tool-bash` consumer — which already decides the access policy — is the single boundary that casts its `SessionId` into an `OwnerToken`. The brand still delivers the safety win (you cannot pass a `BashTaskId` or a raw string where an owner is expected) without the coupling. ## Out of scope / possible extensions @@ -58,10 +58,10 @@ Kept deliberately narrow per the "not every string needs a brand" policy. Each o ## Verification -`BashTaskId` and `OwnerToken` are defined in `dsh-bash` and threaded through the executor, local implementation, and model-facing tool without adding a `dsh-session` dependency. Collections, public parameters, and exported signatures use the applicable brand for `CallId`, `SessionId`, `AgentId`, or `BashTaskId` rather than bare `string`; raw provider, ACP, and model inputs enter through the brand factory instead of scattered casts. +The landed invariants: `BashTaskId` and `OwnerToken` are defined in `dsh-bash` and threaded end-to-end (executor seam, the `dsh-bash-local` generation site, the `dsh-tool-bash` model-facing surface) with no `dsh-bash` dependency on `dsh-session`; no collection keyed by an in-scope branded id (`CallId`/`SessionId`/`BashTaskId`) is keyed by bare `string`; public method params and exported signatures keep the brand; and brands are constructed via the cast factory at each boundary where a raw string enters (provider call id, ACP session id, model-supplied `task_id`), never as scattered `as` casts. ## Consequences -- **Mechanical churn across two surfaces.** Propagating brands touches the bash seam (interface + impl + consumer) and the ACP session-id surface plus the persistence coordinator. The churn is broad but low-severity: a missed site is a compile error, not a silent bug. The change is observably type-only — no snapshot or e2e behavioral diff. It sits next to the [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) proposal (both touch the session-id / owner-token boundary); if that proposal lands, `OwnerToken` still stays distinct from the unified id for the decoupling reason above. +- **Mechanical churn across two surfaces.** Propagating brands touches the bash seam (interface + impl + consumer) and the ACP session-id surface plus the persistence coordinator. The churn is broad but low-severity: a missed site is a compile error, not a silent bug. The change is observably type-only — no snapshot or e2e behavioral diff. It sits next to the [unified agent/session identity decision](../simplification/2026-06-20-unify-agent-and-session-id.md) because both touch the session-id / owner-token boundary; `OwnerToken` stays distinct from the unified id for the decoupling reason above. - **Brands do not validate.** A brand is a confusability guard, not a correctness proof: a *wrong* session id that is still a well-formed string passes the type checker exactly as before. This RFC does not close that gap (see Out of scope) — it only stops the *category* error of passing the wrong *kind* of id. - **The "where to stop" line stays a judgment call.** Branding `BashTaskId` but not `ToolName`, `OwnerToken` but not `ModelId`, is a taste call about which strings "could plausibly be confused." Reasonable reviewers may want more or fewer; the policy in `brand.ts` is the tie-breaker, and this RFC errs toward the ids that are model-facing or used for access control. diff --git a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md index 4cf055179c..b38777be38 100644 --- a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md +++ b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md @@ -24,7 +24,7 @@ This vocabulary is the foundation for interception decisions, the durable `hook/ **The boundary rule:** a durable, replayable fact is a `SessionEvent`; a live interception or a transient/live-object signal is an `agent`/`tools` Cordis event. A turn or step boundary is a durable fact, so it lives in the session log and is read off the `session/event` feed — it is NOT mirrored as an `agent/*` emit. -**Applying the rule to the boundary twins:** all four boundary mirrors — `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end` — are **REMOVED**. No production consumer needs the live `Agent` at a boundary: the ACP bridge settles from `session/event` `turn/end` plus `agent/status`, and the only turn-mirror consumer (`dsh-ui-stdio`, a disposable test REPL) was migrated to render boundaries from `session/event`, recovering the short agent label from an `agent/created`→id map. The step mirrors were removed first (they had no consumer at all); the turn mirrors followed once ui-stdio was migrated — see [the remove-boundary-mirror-events RFC](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md), which owns that decision. Removing the emits also simplifies the loop's `closeStep`/`closeTurn` (one append each, no paired emit). +**Applying the rule to the boundary twins:** all four boundary mirrors — `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end` — are **REMOVED**. No production consumer needs the live `Agent` at a boundary: the ACP bridge settles from `session/event` `turn/end` plus `agent/status`, and the only turn-mirror consumer (`dsh-ui-stdio`, a disposable test REPL) renders boundaries from `session/event` while retaining its live target object for the fixed `main` label. The step mirrors were removed first (they had no consumer at all); the turn mirrors followed once ui-stdio was migrated — see [the remove-boundary-mirror-events RFC](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md), which owns that decision. Removing the emits also simplifies the loop's `closeStep`/`closeTurn` (one append each, no paired emit). ## Consequences diff --git a/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md b/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md index 30674f8c8b..a04040d540 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md +++ b/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -[The prompt-variables RFC](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) makes `dsh-tool-subagent` DERIVE its model-facing wording from its provider: `SubagentProvider.inheritsParentContext` (spawn/ACP `false`, fork `true`) drives both the tool description and the `prompt` parameter description (`providerWording`), so the fork tool stops lying about context inheritance. That fix created a cross-fiber data dependency: a tool's description is fixed at TOOL REGISTRATION (deliberately — the description is where tool-choice guidance lives), but the provider arrives on its own plugin fiber, on no particular schedule. +[The prompt-variables RFC](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) makes `dsh-tool-subagent` DERIVE its model-facing wording from its provider: `SubagentProvider.inheritsParentContext` (spawn/ACP `false`, fork `true`) drives both the tool description and the `prompt` parameter description, so the fork tool stops lying about context inheritance. That fix created a cross-fiber data dependency: a tool's description is fixed at TOOL REGISTRATION (deliberately — the description is where tool-choice guidance lives), but the provider arrives on its own plugin fiber, on no particular schedule. Resolving the provider at the tool plugin's `apply` time creates an implicit load-order requirement ("list the backend before the tool in cordis.yml"). That requirement fails because the Cordis Loader starts sibling entries concurrently and `Entry.init()` does not await activation: a delayed backend can leave the tool fiber failed even when listed first. The Loader offers no sibling-order guarantee — "async state is not synchronous state" ([defensive patterns](../../../defensive-patterns.md)). diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md index dcff4d3220..15477e023a 100644 --- a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md +++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -60,8 +60,7 @@ The ordinary contributor pattern is to register the complete local world during ```js const handle = await ctx.agents.create({ - agentId: AgentId('reviewer'), - sessionId: SessionId('reviewer-session'), + sessionId: SessionId('reviewer'), agentOptions: { model: 'model-name' }, setup(agentCtx) { agentCtx.systemPrompt.section({ diff --git a/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md b/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md index 77fa2b4669..64961bee89 100644 --- a/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md +++ b/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md @@ -8,11 +8,11 @@ An ACP editor can keep several conversations alive over one agent subprocess. A ## Decision -The ACP bridge stores live sessions in `Map` and keeps a `WeakMap` reverse index for agent-scoped callbacks. A record owns its agent handle, in-flight prompt, live tool-call presentation state, pending idle config switches, session cwd, and client capability snapshot. A separate loading-id set reserves each id before asynchronous resume so two pipelined loads cannot construct duplicate agents; distinct ids may load concurrently. +The ACP bridge stores live sessions in `Map`. Agent-scoped callbacks use `ownedRecord`: look up `agent.session.id` in that forward map and accept the record only when it owns the exact agent object, so a foreign same-id object cannot claim the session. A record owns its agent handle, in-flight prompt, live tool-call presentation state, pending idle config switches, session cwd, and client capability snapshot. A separate loading-id set reserves each id before asynchronous resume so two pipelined loads cannot construct duplicate agents; distinct ids may load concurrently. Every `session/event` and `agent/status` callback resolves the owning record before sending or settling anything. Each session permits one in-flight prompt independently. The prompt records a log watermark, captures its own `turn/start`, and settles only on the matching `turn/end`; a late end from a cancelled prior turn cannot resolve a newer prompt. `session/cancel` addresses one record and calls only that agent's queue-aware cancel path. -Permission ownership uses the same reverse index. The ACP `approval/request` answerer prompts only the editor session that owns the requesting agent and delegates foreign requests. User-interaction elicitations likewise route by agent ownership. Per-session sandbox and approval config values fold only that session's events, with pending idle switches stored on that record until the next turn anchors them. +Permission ownership uses the same exact-agent check against the forward map. The ACP `approval/request` answerer prompts only the editor session that owns the requesting agent and delegates foreign requests. User-interaction elicitations likewise route by agent ownership. Per-session sandbox and approval config values fold only that session's events, with pending idle switches stored on that record until the next turn anchors them. Background bash tasks carry an opaque owner token equal to the owning session id. `bash_output` and `bash_kill` compare the caller's token with the executor's task ownership before reading or killing; a predictable task id alone grants no access. Ownership is stored with the executor task, so a tool plugin reload does not erase it. diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md index 864428bc58..1986ad7624 100644 --- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -22,7 +22,7 @@ Per the [capability-seams RFC](../../implemented/architecture/2026-06-13-capabil ### The contract depends on `dsh-session` and `dsh-llm` — a deliberate deviation -The capability-seams RFC states the interface package "depends only on cordis" (true of `dsh-bash`, whose vocabulary is self-contained). Compaction **cannot** honor that: its verbs are defined *over* a `Session` (`compactRegion(session, start, end)`) and its output *is* the content vocabulary (`CompactionResult.summary: ContentBlock[]`). There is no way to express the contract without naming `Session`/`SessionEvent` (from `dsh-session`) and `ContentBlock` (from `dsh-llm`). +The capability-seams RFC states the interface package "depends only on cordis" (true of `dsh-bash`, whose vocabulary is self-contained). Compaction **cannot** honor that: its verbs act on an agent-owned `Session` (`compactRegion(start, end, agent)`) and its output uses the content vocabulary (`CompactionResult.summary: ContentBlock[]`). There is no way to express the contract without naming `Session`/`SessionEvent` (from `dsh-session`) and `ContentBlock` (from `dsh-llm`). This is not a coupling smell — it is the contract's domain. The "only cordis" guidance was always shorthand for "the interface depends only on what the contract genuinely names, and never on an implementation." `dsh-session` and `dsh-llm` are themselves interface/vocabulary packages, not implementations; `dsh-compact` still imports no backend. The seam's real invariant — *consumers and implementations evolve independently behind an abstract service* — holds intact. @@ -30,7 +30,7 @@ This is not a coupling smell — it is the contract's domain. The "only cordis" An earlier draft put the full algorithm (the retention walk, token-summing, text extraction) as concrete methods on the interface. That recouples the contract to one strategy: a backend that wants a different retention policy or event sequence would have to fight inherited concrete code. Making both core methods abstract puts every *how* decision in the backend and keeps the interface a statement of *what*. Token measurement is not a compaction hook at all; the singleton service lets multiple consumers share one per-session replay fold. -`compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` takes required pressure inputs and cancellation. The session comes from the agent. `compactRegion(session, start, end, agent, signal?)` keeps an optional signal for manual callers and requires `session === agent.session`; implementations reject mismatch before model resolution, lock acquisition, summarization, or log mutation. The pre-step integration resolves a provisional model from the latest logged request header, then `AgentOptions.model`; a model-less router-only first step skips pressure because `agent/request` can route later. The default summarizer resolves its model from explicit config, the latest logged routed model, then agent options. +`compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` takes required pressure inputs and cancellation. `compactRegion(start, end, agent, signal?)` uses `agent.session` as its single session identity and keeps an optional signal for manual callers. The pre-step integration resolves a provisional provider/model pair from the latest logged request header, then `AgentOptions`; a model-less router-only first step skips pressure because `agent/request` can route later. The default summarizer resolves its target from explicit config, the latest logged routed target, then agent options. ### Auto-compaction runs on `agent/pre-step`, a dedicated surface-mutation seam diff --git a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md index 83c7beb440..11824bcf63 100644 --- a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -10,7 +10,7 @@ The harness has a long-deferred seam for **subagents** — an agent delegating w The distinctive requirement — the one that shapes the whole design — is that **multiple subagent implementations must coexist at runtime**. A parent may want a cheap in-process child for a scoped subtask AND an isolated out-of-process child (over ACP) in the same session. The transports we foresee: -- **in-process** — a child `ReactLoopAgent` on the same `Context` (the cheapest, and nearly free given the existing agent factory); +- **in-process** — a child concrete `Agent` on the same `Context` (the cheapest, and nearly free given the existing agent factory); - **ACP** — act as an ACP *client* driving another agent process (which can be another instance of ourselves); - later: **A2A**, the **Codex app-server**, and the **Claude Code Agent SDK** — each the same out-of-process "start a child, prompt it, stream updates, cancel" shape as the ACP backend. diff --git a/docs/rfc/implemented/feature/2026-06-25-ask-user-question.md b/docs/rfc/implemented/feature/2026-06-25-ask-user-question.md index d7c6631b94..8320dc4aa5 100644 --- a/docs/rfc/implemented/feature/2026-06-25-ask-user-question.md +++ b/docs/rfc/implemented/feature/2026-06-25-ask-user-question.md @@ -22,7 +22,7 @@ Providers return `{ answers: [{ id, selected, custom? }] }`. `selected` is alway `dsh-stdio-demo`'s in-package readline module renders each question, shows each option's `description` on the next line, supports comma/space-separated numeric choices for `multi_select`, accepts free-form custom answers, and rejects pending questions on abort, provider disposal, or stdin EOF. A batched request is asked in order and resolved as one answer object. The stdio provider serializes simultaneous requests with an internal queue so only one prompt owns stdin at a time. -`dsh-acp` provides the same seam for ACP sessions. It routes an ask request from the calling `Agent` through the bridge's `agent→sessionId` reverse map and calls ACP `unstable_createElicitation` with a session-scoped form for each question. Single-select options become a `choice` string enum; `multi_select` options become a `choice` array enum; optionless questions use a required `custom` text field. If the client returns both `choice` and non-empty `custom`, the custom answer wins. ACP `decline`/`cancel`, a missing answer, a missing session, and a client without elicitation support all become structured `UserInteractionError`s. +`dsh-acp` provides the same seam for ACP sessions. It resolves the calling `Agent` through `ownedRecord`, requiring the forward session-map record at `agent.session.id` to own that exact agent object, and calls ACP `unstable_createElicitation` with a session-scoped form for each question. Single-select options become a `choice` string enum; `multi_select` options become a `choice` array enum; optionless questions use a required `custom` text field. If the client returns both `choice` and non-empty `custom`, the custom answer wins. ACP `decline`/`cancel`, a missing answer, a missing session, and a client without elicitation support all become structured `UserInteractionError`s. The ACP mapping deliberately uses elicitation, not `session/request_permission`. `request_permission` is still reserved for the separate permission gate: it is a yes/no-or-policy authorization protocol around tool execution. `ask_user_question` is a general information-gathering tool with optional free-form answers, so ACP form elicitation is the closer protocol fit. The bridge's session routing is shared with the future permission gate, but the user intent is different. diff --git a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md index 4ac066e393..81e60a726f 100644 --- a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md +++ b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md @@ -25,7 +25,7 @@ One `cordis.yml` entry mounts the seam. Not loading it is the fail-closed opt-ou The entry alone provides mechanism, not a channel: with no answerer composed, every ask resolves `unavailable` and the asking tool call denies — fail-closed needs no configuration. Composing the ACP app (`@deepseek-ai/dsh-acp-demo`, as in [the acp-agent example's default tree](../../../../examples/acp-agent/README.md)) completes the loop: its bridge registers an answerer that prompts the owning editor session via `session/request_permission`, so a hook's `ask` or an escalation request surfaces as a one-shot Allow/Reject prompt attached to the already-streamed tool call. `policy: never` is the unattended stance — every ask auto-rejects deterministically, stated in the system prompt, no human in the loop. `policy` is validated against the closed list at plugin load; anything else throws. -What a composed deployment observes: `allowed-once` lets exactly that call proceed; rejection, dismissal, and channel absence deny with three distinct reasons the model can tell apart; every ask lands a durable `approval/asked`/`approval/decided` pair on the asking agent's session log; nothing about a grant persists past the call that asked. +What a composed deployment observes: `allowed-once` lets exactly that call proceed; rejection, dismissal, and channel absence deny with three distinct reasons the model can tell apart; a successful in-turn request lands a durable `approval/asked`/`approval/decided` pair on the asking agent's session log; nothing about a grant persists past the call that asked. An idle request or audit append failure rejects instead of returning an unaudited decision. One ask under this composition, verbatim from the sandbox example's recorded `escalation-approved` scenario — the model requests a sandbox escalation, the gate asks, the bridge prompts the owning editor, the user clicks Allow once: @@ -49,45 +49,44 @@ The `escalation-rejected` twin ends in `{"outcome": "rejected"}` instead: nothin #### The seam: mechanism and policy split -After validation and an `approval/asked` append, `request()` resolves to `allowed-once`, `rejected`, `cancelled`, or `unavailable`. The service borrows the readonly request, runs the answerer waterfall, races cancellation, and normalizes thrown or invalid answers to `unavailable`. It then appends the matching `approval/decided`, paired by `ApprovalRequestId`. +After validation and a successful `approval/asked` append, the service resolves the `approval/request` waterfall to `allowed-once`, `rejected`, `cancelled`, or `unavailable`. It borrows the readonly request identity and signal, treats abort as `cancelled`, contains answerer failures and invalid returns as `unavailable`, discards late answers, and appends the paired `approval/decided` event. Pre-commit audit failures reject; post-append observer failures cannot undo an authoritative event. `allowed-once` authorizes only the asked action, and `request()` rejects outside an open turn so the audit pair remains inside the durable commit boundary. -Both audit events must be inside an open turn; acceptance or a pre-commit append failure rejects the request. Post-commit observers are contained by the session. `allowed-once` grants only the requested action, and the service retains no grant state. +Answerers are `approval/request` waterfall listeners. Zero listeners fall through to `unavailable`; a recognizing listener occupies the first-wins decision slot, while an unrecognized agent must delegate with `next()`. Listeners dispose with their fibers, so an unloaded channel fails closed. Because sibling registration order is not deterministic, a deployment composes one terminal answerer and reserves `prepend` for decide-or-delegate gates. -Answerers are `approval/request` waterfall listeners. A listener returns an outcome for an agent it owns and calls `next()` otherwise. With no answerer, the default is `unavailable`; unloading a UI therefore fails closed without leaving a channel. Because sibling registration order is not deterministic, a deployment composes one terminal answerer and uses `prepend` only for decide-or-delegate gates. - -`ApprovalRequest` carries the agent, tool name, optional `callId`, reason, and signal. The agent routes both the prompt and audit events. The request uses `dsh-llm`'s `CallId` without importing `dsh-tools`, avoiding a package cycle. Tool arguments are omitted because UI answerers attach to the already-rendered call. +`ApprovalRequest` carries the asking `agent`, `toolName`, optional exact `callId`, human-readable `reason`, and optional `signal`. It uses the `CallId` brand without importing `dsh-tools`, which depends on this seam. Tool arguments stay on the already-streamed call that a UI references by `callId`. #### Ask routing in dsh-tools -`ToolRegistry.execute()` sends `ask` through the approval seam before the deny path. Only `allowed-once` proceeds; rejection, cancellation, and an unavailable channel produce distinct model-visible reasons. The registry looks up the optional service per call, so an absent or unloaded service fails closed without gating the registry fiber. Agent-less execution also fails closed because it cannot be routed or audited. +`ToolRegistry.execute()` resolves `ask` before dispatch: `allowed-once` proceeds, while rejection, cancellation, and channel absence produce distinct deny reasons. Opportunistic `ctx.get('approval')` consumption lets an absent or unmounted service fail closed without gating the registry fiber. Agent-less execution also fails closed because it has neither an audit session nor a UI owner. #### The per-session policy tier -The seam owns the session policy `'ask' | 'never'`, following the switching contract in the [sandbox RFC](2026-07-06-sandbox.md). The effective session or config policy is applied before answerers: `'never'` rejects inside `request()`, while `'ask'` dispatches and falls through to `unavailable` when unanswered. The prompt states only deterministic `'never'`; the narrator reports switches, and every request still receives its audit pair. +The seam also owns the session-scoped `'ask' | 'never'` policy described by [the sandbox RFC](2026-07-06-sandbox.md). Effective policy is folded from logged switches over the deployment default. `'never'` resolves to `rejected` inside `request()` before any answerer can run; `'ask'` dispatches and otherwise falls through to `unavailable`. The prompt states only deterministic `'never'`, switch narration is coalesced, and every request still records the audit pair. #### The ACP answerer -The ACP bridge finds the owning session, sends `session/request_permission` for the `callId`, and maps one-shot allow, reject, and cancel responses to the seam vocabulary. Unknown selections never grant. Foreign agents and requests without a `callId` delegate via `next()`; RPC failure becomes `unavailable`. The bridge answers requests but does not decide which calls require approval. +The ACP bridge answers only for an exact agent object owned by its forward session map. It attaches `session/request_permission` to the existing `callId`, advertises one-shot allow/reject options, maps cancellation separately, and never grants an unknown option. Foreign or call-less requests delegate; a failed client RPC becomes `unavailable`. Hooks and `tools/pre-execute` decide whether a call asks at all. -The answerer routes through the bridge's reverse-map ownership seam described by [the ACP support RFC](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md), implementing the per-session permission ownership required by [the multi-session RFC](../../implemented/feature/2026-06-14-acp-multi-session.md). +The answerer routes through the bridge's exact-agent ownership check described by [the ACP support RFC](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md), implementing the per-session permission ownership required by [the multi-session RFC](../../implemented/feature/2026-06-14-acp-multi-session.md). #### Audit, and what the model sees -`approval/asked` and `approval/decided` are durable log-only events. The model sees only the asker's logged `tool/result`. Every accepted request appends one matching decision, including cancellation and contained answerer failures. +`approval/asked` and `approval/decided` are durable log-only events; the model sees only the ordinary tool result derived from the outcome. Successful completion commits one `decided` per `asked`, including cancellation and contained answerer failure. Idle requests append neither event; a pre-commit failure rejects, while failure of the second append can leave an already-committed `asked` unmatched. #### Entities and dependencies -`dsh-user-approval` owns the fixed dispatch-and-audit mechanism; `dsh-tools` asks and `dsh-acp` answers. Replaceable answerers remain listeners in their channel-owning plugins, so a three-package capability split would add an empty implementation layer. Sandbox executors remain transport-only, and static capability grants remain separate from interactive approval. +`dsh-user-approval` depends on Cordis plus the session, agent, and branded-call contracts; `dsh-tools` and `dsh-acp` consume it. The sandbox executor stays independent because `dsh-tool-bash` owns escalation requests. The fixed dispatch-and-audit service remains one package; replaceable answerers live with their channel owners. Static capability grants and `subagent-acp` child-side permission answers remain separate concerns. ### Testing -- **Unit/integration:** cover first-wins delegation, fail-closed defaults, malformed and throwing answerers, cancellation races and late-answer discard, audit pairing despite observer failures, unbypassable `'never'`, distinct tool-denial reasons, and ACP per-session routing/outcome mapping. -- **Snapshot:** script permission answers through both sandbox escalation branches and pin the `'never'` prompt plus policy-switch notice. Hook-produced asks without a composed answerer remain covered as fail-closed denial. +Unit tests pin outcomes, first-wins delegation, containment, cancellation, scoped routing, audit pairing, the unbypassable `'never'` policy, tool deny reasons, and ACP ownership/outcome mapping through a real scripted bridge. + +Snapshots record allowed and rejected sandbox escalation through `session/request_permission`, plus the `'never'` prompt and policy-switch notice. Unscripted permission prompts cancel and fail closed. ## Deferred - **`allow_always` grant storage** — honoring a persistent grant means designing storage, scope identity (call? path? prefix? session? time window?), and revocation; until designed, only the one-shot options are advertised ([the sandbox RFC](2026-07-06-sandbox.md) § Escalation records the open scope question). -- **A recorded hook-produced ask with a composed answerer** — escalation records the human-prompt wire, while the current hook fixture pins the no-service denial; their combined producer/answerer path remains unit-covered. +- **A recorded hook-driven `ask` through a composed answerer** — the human-prompt wire is recorded through the sandbox example's escalation branches. The hook matrix's `hook-cc-pretool-ask` pins the no-ApprovalService fallback denial, while the hook-producer-plus-answerer composition remains on the unit tier. - **Routing a child agent's approvals to the parent session** — `subagent-acp`'s child auto-answers its own `permission` requests; surfacing them to the parent's editor is its own design. ## Alternatives considered @@ -101,16 +100,18 @@ The answerer routes through the bridge's reverse-map ownership seam described by ## Consequences -- Only `allowed-once` dispatches an asked-about action; absent, rejected, cancelled, or failed answer paths deny. -- Session ownership routes prompts, policy, and audit events without crossing editor sessions. -- Accepted requests append one durable audit pair; the model sees only the resulting tool result. -- A deployment without the service emits no approval prompt or audit events and denies every `ask` at the tool boundary. +The implemented contract is pinned by the suites in Testing: + +- `allowed-once` dispatches one action; every other outcome denies with a distinct reason, and `'never'` rejects before prompting. +- Missing, foreign, agent-less, throwing, invalid, and disconnected answer paths fail closed. +- Successful requests route by exact agent ownership and append one replayable, model-invisible audit pair; idle and pre-commit failures reject. +- ACP ownership keeps prompts inside their session, while a deployment without the service emits no prompt or audit events. Costs and accepted limits: - **Two decide-eager answerers race for the slot.** Sibling-plugin listener order is not deterministic, so the seam cannot referee competing terminal answerers — mitigated by convention (one terminal answerer per deployment; `prepend` only for decide-or-delegate gates) rather than a priority mechanism the event bus does not have. - **Production exercise rests on one composition.** `ask` has two producer families — the hook bridges through `tools/pre-execute`, and sandbox escalation through its own gate — with the wire recorded in the sandbox example's snapshot suite, so the seam's real-world coverage is that one composition until more deployments compose it. -- **Ownership keys on `Agent` object identity.** The answerer resolves sessions through the bridge's existing WeakMap; every current path hands the same object through the loop and the seams, but a future boundary that clones or proxies agents would make the bridge delegate and fail closed — safe, but silently UI-less — and would need session-id matching instead. +- **Ownership keys on `Agent` object identity.** The answerer resolves the forward session-map record at `agent.session.id`, then requires that record to own the exact agent object; every current path hands the same object through the loop and the seams, but a future boundary that clones or proxies agents would make the bridge delegate and fail closed — safe, but silently UI-less — and would need a different ownership contract. ## FAQ @@ -118,10 +119,10 @@ Costs and accepted limits: - **Can a grant persist — "always allow this"?** No. `allowed-once` authorizes the single asked-about action and the service stores nothing between requests; `allow_always` is deliberately not advertised until grant storage is designed (§ Deferred). - **What does the model see of an approval?** Only the tool result the asker derives from the outcome — the audit pair never enters the transcript. The three non-grant reasons are distinct, so the model can tell a human "no" from a dismissed prompt from a missing channel. - **Who decides whether a call asks in the first place?** Policy producers: a hook returning `permissionDecision: ask`, any `tools/pre-execute` listener, or the sandbox escalation gate. The seam and the bridge only route and answer; neither injects its own judgment about what deserves a prompt. -- **What happens when the user dismisses the prompt, or the turn aborts mid-ask?** Dismissal maps to `cancelled` with its own deny text. An already-aborted signal settles `cancelled` without dispatching; an abort during the ask discards the late answer — one audit pair either way, never two. +- **What happens when the user dismisses the prompt, or the turn aborts mid-ask?** Dismissal maps to `cancelled` with its own deny text. An already-aborted signal settles `cancelled` without dispatching; an abort during the ask discards the late answer. When both audit appends commit, either path records one pair, never two. - **What if the client answers with an option the harness never offered?** Any selection other than the offered `allow_once` maps to `rejected` — an unknown optionId from a non-conforming client can never grant. - **How do subagents' approvals route?** An agent no answerer owns delegates through the whole waterfall and fails closed — in-process subagents are deliberately unanswerable. `subagent-acp`'s child-side auto-answer is separate; routing a child's asks to the parent's editor is deferred (§ Deferred). -- **What does `policy: 'never'` actually change at runtime?** The service resolves every ask for that session to `rejected` before dispatching any answerer (in-service, so no registration order can bypass it); the system prompt states the policy; switches are narrated at boundaries; the audit pair still lands for every auto-rejection. +- **What does `policy: 'never'` actually change at runtime?** The service resolves every ask for that session to `rejected` before dispatching any answerer (in-service, so no registration order can bypass it); the system prompt states the policy; switches are narrated at boundaries; each successful auto-rejection records the audit pair. - **What happens across a hot reload, or when the UI plugin unloads mid-session?** Answerers dispose with their owning fiber, so the next ask degrades to `unavailable` instead of hanging on a dead channel; remounting re-registers the answerer with no catch-up state. - **Where does the user see what they are approving?** On the tool call itself: the prompt attaches to the already-streamed call via `callId` — arguments included — and adds the asker's human-readable `reason`; the request carries no argument copy of its own. @@ -132,5 +133,5 @@ In-repo precedents this design copies or contrasts with: - The `fs/write-intent` gate (`packages/fs/fs/`) — the documented single-occupancy decision-slot waterfall semantics (first answer wins, delegate via `next()`) the answerer contract reuses. - `hook/invoked`/`hook/result` — the log-only audit-pair precedent `approval/asked`/`approval/decided` follows; [the hook-bridges RFC](2026-06-30-hook-bridges.md) ships `permissionDecision: ask`, the first producer. - [The interception-seams RFC](2026-06-30-interception-seams.md) — the `tools/pre-execute` `allow`/`deny`/`ask` vocabulary whose `ask` this seam services. -- [The ACP support RFC](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md) — the `WeakMap` ownership seam the answerer routes through; [the multi-session RFC](../../implemented/feature/2026-06-14-acp-multi-session.md) — the per-session permission-ownership blocker this implements. +- [The ACP support RFC](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md) — the exact-agent ownership check against the forward session map that the answerer routes through; [the multi-session RFC](../../implemented/feature/2026-06-14-acp-multi-session.md) — the per-session permission-ownership blocker this implements. - The opportunistic `ctx.get()` consumption pattern (`tool-bash`'s owner-token lookup, the loop's persistence probe) — how `dsh-tools` consumes the seam without gating its fiber on it. diff --git a/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md index 4996fc2935..74f4216565 100644 --- a/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md +++ b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md @@ -12,11 +12,10 @@ The harness already has every seam the pi extension uses, and better ones: [the The guard is a loop-hygiene plugin, not a model-facing tool. It counts consecutive calls to the same tool with identical canonical arguments and injects advisory reminders at configured thresholds. It never delays, blocks, or rewrites a call; the model decides whether to retry differently or finish. -The plugin is `@deepseek-ai/dsh-repeat-tool-guard` at `packages/guard/repeat-tool-guard/`, opening the `guard/` group for loop-hygiene plugins (single-package groups have precedent: [the todo-write RFC](2026-06-29-todo-write-tool.md) shipped `todo/tool-todo`). It registers three listeners and holds all state in plugin-local maps keyed by `AgentId` — the tool registry is a context-level singleton whose waterfalls interleave every agent's calls (subagents run on the same context), so per-agent keying is correctness, not polish. +The plugin is `@deepseek-ai/dsh-repeat-tool-guard` at `packages/guard/repeat-tool-guard/`, opening the `guard/` group for loop-hygiene plugins (single-package groups have precedent: [the todo-write RFC](2026-06-29-todo-write-tool.md) shipped `todo/tool-todo`). It registers two listeners and holds state in a `WeakMap` keyed by the live `Agent` object — the tool registry is a context-level singleton whose waterfalls interleave every agent's calls (subagents run on the same context), so per-agent keying is correctness, not polish; weak object keys also make a disposal-only cleanup listener unnecessary. - **`tools/post-execute` (waterfall)** — the one detection point. The listener receives `(exec, result)` together, so counting and reminder delivery need no cross-event pending map (the pi extension needs one only because its `tool_call`/`tool_result` hooks are separate events). It always delegates via `next()` and, when a threshold is hit, prepends a reminder to the downstream decision's `additionalContexts` — the observe-and-enrich posture [the hooks bridges](2026-06-30-hook-bridges.md) already use, honoring the waterfall contract. Counting happens here rather than in `tools/pre-execute` because post-execute also runs for denied calls (`ToolRegistry.execute` routes a deny through the same pipeline), and a model hammering a denied call is exactly the loop worth breaking. - **`agent/prompt-submit` (waterfall)** — pure reset hook: delegate via `next()`, clear the submitting agent's chain. A user interjection changes the context; repetition across it is not a loop. -- **`agent/status` (emit)** — on `disposed`, drop the agent's state, bounding the maps over harness lifetime. ### Detection semantics @@ -25,7 +24,7 @@ The chain key is `(tool name, canonical arguments)`; a call identical to the pre Two deliberate rules, both documented in [the package README](../../../../packages/guard/repeat-tool-guard/README.md) because they are behavior a reader would otherwise guess at: - **Untracked calls are transparent to the chain.** A call excluded by `include`/`exclude` neither increments nor resets the counter, so `grep X → todo_write → grep X` still counts as two consecutive `grep X` when `todo_write` is excluded. This is what makes exclusion useful — bookkeeping tools interleaved into a loop must not launder it — and it is the pi extension's (undocumented) semantics, kept on purpose and written down. -- **Calls without an agent are ignored.** A direct `ctx.tools.execute()` caller (tests, non-loop consumers) has no model to remind and no `AgentId` to key on. +- **Calls without an agent are ignored.** A direct `ctx.tools.execute()` caller (tests, non-loop consumers) has no model to remind and no live agent object to key on. ### Reminder delivery diff --git a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md index e2a1165846..f4b49281ea 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md +++ b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md @@ -2,9 +2,18 @@ Status: implemented + + ## Problem -The loop exposed durable turn and step boundaries through both the replayable `SessionEvent` log and live `agent/*` mirrors. Consumers had to choose between two sources for the same fact and reconcile their timing. ACP and persistence already used the log; the stdio UI was the only remaining mirror consumer and already rendered tool calls and results from `session/event`. +The loop records the canonical transcript in `SessionEvent` and also emitted a parallel set of live `agent/*` boundary mirror events: `agent/turn-start`, `agent/turn-end`, `agent/step-start`, and `agent/step-end`. The mirrors made consumers choose between two sources of truth for the SAME durable fact. ACP already chose the session log for the editor-facing transcript because it is the one durable, replayable record; consuming a live mirror would require reconciling its timing with the boundary already stored in that log. The stdio UI was the only production consumer that still rendered turn boundaries from the mirror events; it already rendered tool calls and results from `session/event`. This duplication is not free. Every lifecycle change had to update the session event, the mirror event, docs, invariants, tests, and snapshot expectations. The duplicate boundary events also made failure ordering subtle: a turn can be durably closed before a live `agent/turn-end` listener runs, so a post-boundary listener failure has no valid in-log position left and must be reported out of band. @@ -12,19 +21,25 @@ This duplication is not free. Every lifecycle change had to update the session e Make `session/event` the single live boundary/transcript stream. Consumers that render turns, tool calls, tool results, assistant messages, and durable boundaries subscribe to `session/event` and derive their UI from the same event vocabulary persistence uses. -Remove `agent/turn-start`, `agent/turn-end`, `agent/step-start`, and `agent/step-end`. Boundary consumers subscribe to `session/event`. A UI that needs an agent label maintains a session-to-agent map from `agent/created` and `agent/disposed`, because the durable `turn/start` carries the turn number but not the agent id. +The four durable-boundary mirrors — `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end` — are removed from the agent event taxonomy. A UI that wants the agent handle at a boundary retains the live target object from `agent/created`/`agent/disposed` and compares its session directly; `dsh-ui-stdio` uses this to label the app-owned agent's `[main turn N]` header while other sessions render their durable id. The canonical record remains the event-sourced session log. -The step mirrors had no consumers and were removed first by the [event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md). That decision retained the turn mirrors for the stdio UI; this RFC removes them after migrating that test REPL to `session/event` and the id map. +The step mirrors (which had no consumer at all) were removed first, in [the event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md); that RFC KEPT the turn mirrors on the stated justification that the stdio UI needed the `Agent` handle at the turn boundary. This RFC finishes the job: `dsh-ui-stdio` is a disposable test REPL whose rendering can change freely, so "ui-stdio needs it" is not a reason to keep a mirror — it reads `session/event` and retains only its live target object. ## Scope: what is and isn't removed -This decision covers only durable turn and step boundaries. `agent/steering` mirrored a control record and `agent/stream-chunk` mirrored the token stream, so each was handled separately: [steering](2026-07-04-remove-agent-steering-mirror.md) and [stream chunks](2026-07-02-remove-stream-chunk-mirror.md). `agent/created`, `agent/disposed`, `agent/status`, `agent/error`, and `agent/queued` remain live lifecycle or control events rather than transcript mirrors; queued input may be cancelled before any durable event exists. +Removed (durable-boundary mirrors — the session log is authoritative for each): `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end`. + +RETAINED — NOT durable-boundary mirrors, so out of scope for this decision: + +- `agent/steering` — not a boundary, so out of scope for THIS decision (the original proposal bundled it into the removal; that would have been scope creep here). It mirrors the durable `steering/message` control record rather than a boundary, and was removed by its own follow-up: [Remove the `agent/steering` mirror emit](2026-07-04-remove-agent-steering-mirror.md). +- `agent/stream-chunk` — the live token stream. Out of scope for THIS decision (a mirror of the durable `assistant/chunk`, not a boundary), it was removed by its own follow-up: [Stop mirroring the token stream as an agent event](2026-07-02-remove-stream-chunk-mirror.md). +- `agent/created`, `agent/disposed`, `agent/status`, `agent/error`, `agent/queued` — lifecycle/control events that are not transcript data. `agent/queued` in particular is an inbox acknowledgement that fires before any durable event exists (cancelled queued work may never enter the log), so it is deliberately live-only. ## Alternatives considered -- **Remove `agent/steering` in the same change** — rejected because it was a control-record mirror rather than a boundary mirror. -- **Keep turn mirrors for the stdio UI** — rejected because the UI can render `session/event` and recover the agent label from its id map. +- **Bundling `agent/steering` into the removal** — the original proposal's shape; narrowed out as scope creep: it mirrors the durable `steering/message` control record, not a boundary, and was removed by [its own later decision](2026-07-04-remove-agent-steering-mirror.md) (as was `agent/stream-chunk`, by [the stream-chunk-mirror RFC](2026-07-02-remove-stream-chunk-mirror.md)). +- **Keeping the turn mirrors for the stdio UI** — [the event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md)'s original stance; rejected here because `dsh-ui-stdio` is a disposable test REPL, not a load-bearing consumer, and it renders boundaries from `session/event` plus its live target object instead. ## Consequences -A plugin can no longer observe turn/step boundaries from a convenient `Agent`-first event. It must either subscribe to `session/event` or maintain a session-to-agent association. That is an acceptable trade: boundary consumers should not depend on a second event feed that can drift from the durable log. +A plugin can no longer observe turn/step boundaries from a convenient `Agent`-first event. It subscribes to `session/event` and, if it needs the live object, resolves the shared id through `ctx.agents` or retains the object it already owns. That is an acceptable trade: boundary consumers should not depend on a second event feed that can drift from the durable log. diff --git a/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md b/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md new file mode 100644 index 0000000000..9084befad5 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md @@ -0,0 +1,38 @@ +# RFC: Unify the agent id and the session id + +Status: implemented + +## Problem + +A live agent/session pair needs one identity for registry routing, event sourcing, and persistence. Giving the factory independent `agentId` and `sessionId` inputs would permit pairings no production path can use, while forcing every consumer to choose or translate between two names for the same lifecycle. + +ACP uses the same value for both identities. Stdio and hooks also operate on the session event stream and need the corresponding live agent directly; no production path reattaches one live agent object to several sessions or drives one session through several agent ids. + +The [agent-scope runtime](../../implemented/architecture/2026-07-12-agent-scope-runtime-design.md) uses one `AgentCreationTransaction` for create and resume, and agent/session entries share the same final-entry collision rule. A second identity would not represent separate liveness, rollback, or quiescence; it would only add API and translation state around the same transaction. + +Session identity likewise has one home in `Session.header.id`; `Session.id` is a derived accessor rather than independent state that needs duplicate validation. + +## Decision + +An agent's registry id equals its session id. `CreateAgentOptions` accepts one `sessionId` used for both final registry entries; resume registers the agent under `resumeSessionId`; in-process subagent creation uses the child session id; and `Session.id` derives from `header.id`. A remote ACP run has no local agent/session pair: it keeps one parent-minted lifecycle id while the child server's wire-local session id remains private to ACP calls. The existing creation transaction, final-entry collision checks, and exact-entry detach semantics remain; maps and fields whose sole job was translating between local ids are gone. + +The config-driven path keeps `agents[].id` as a stable configuration label, not a live routing identity. An ordinary fresh start mints the combined id `${label}-session-${randomUUID()}` so durable restarts do not collide. A coupled app may pre-mint and pass an exact `sessionId`: first use creates it, while an AgentLoop remount with an already-present persistence service resumes materialized history under that same identity. `resumeSessionId` instead requires an existing persisted identity. The two exact-id inputs are mutually exclusive. Stdio uses the resume-or-create form so its config-created agent and UI share one opaque identity across loop reloads instead of guessing from a prefix. Logs may use the stable label while all live and durable lookups use the one `SessionId`. + +`agent/created` and `agent/disposed` remain. They are paired publication lifecycle events, not identity aliases; any later consumer-free removal needs its own proposal after a fresh search. + +## Alternatives considered + +**Keep separate routing and log identities.** A stable configured label plus a fresh durable conversation is useful, but it does not require two live identities: the label can remain configuration/display metadata while the combined per-run `SessionId` owns routing and persistence. Keeping two ids would preserve translation maps and permit impossible pairings without adding lifecycle capability. + +## Verification + +- Agent create/resume and subagent creation carry one identity, and `Session` stores it in one place. +- The creation transaction retains final-entry collision, exact-entry detach, rollback, and quiescence coverage without identity-specific lifecycle state. +- ACP, stdio, hooks, bash ownership, persistence, and lineage use the shared `SessionId` directly. The ACP subagent backend mints its lifecycle id in the parent namespace because a child server's returned session id is only server-local; the ACP bridge verifies exact `Agent` ownership from the forward session map; and JSON-RPC forwards only lifecycle events whose service-snapshotted `local` flag is true, obtains the delegating parent from the scoped event carrier, and keeps no child identity or lineage cache. +- The config-driven resume-or-create policy is explicit and covered across a durable restart. +- A production listener search kept `agent/created`/`agent/disposed` and their publication semantics. +- Typecheck, coverage, snapshots, doc-sync, module-graph verification, build, and hygiene pass. + +## Consequences + +This forecloses latent multi-session-actor and session-handoff designs and makes persisted client-chosen session identity the registry identity. If separate routing identity becomes a real requirement, it needs an explicit lifecycle design rather than an unconstrained caller-supplied pair. diff --git a/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md b/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md index a272dfe0b2..6ca85b3d16 100644 --- a/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md +++ b/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md @@ -42,4 +42,4 @@ Not touched: ## Consequences -A plugin can no longer observe token deltas from an `Agent`-first event. It subscribes to `session/event` and filters `assistant/chunk` (the `Agent` handle, if needed, is recovered from a session-id→agent map built from `agent/created`/`agent/disposed`, exactly as boundary consumers already do). No production consumer needed the live `Agent` at chunk time; this is the same acceptable trade the boundary-mirror removal made. +A plugin can no longer observe token deltas from an `Agent`-first event. It subscribes to `session/event`, filters `assistant/chunk`, and looks up the corresponding live handle directly with `ctx.agents.get(session.id)` when needed. No production consumer needed the live `Agent` at chunk time; this is the same acceptable trade the boundary-mirror removal made. diff --git a/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md b/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md index 67404ab49a..db7a9c8a79 100644 --- a/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md +++ b/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md @@ -20,7 +20,7 @@ Adopt `fast-check` (a root devDependency) with one `tests/properties.spec.ts` pe ## Consequences - Generator quality is the value lever — the generators bias toward small index pools and short strings so collisions and interleavings are common. -- **It already paid off:** the BlockAssembler stream found a real bug — a duplicate `block-end` at the same index overwrote an already-flushed block, so the streamed prefix disagreed with final `blocks()`. Fixed (first close wins, matching the existing straggler rule) with a dedicated regression test. +- **It already paid off:** the BlockAssembler stream found a real bug — a duplicate `block-end` at the same index rewrote a completed block. Fixed (first close wins, matching the existing straggler rule) with a dedicated regression test. - A property flake from a timeout is a finding, not something to retry away. The loop properties are deterministic by construction (settle on `agent/status`), so a hang is a real defect. - Property tests supplement, not replace, the example tests that pin specific branches for the 100%-coverage gate. diff --git a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md index 5f05c92317..238f2044ad 100644 --- a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md +++ b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md @@ -28,7 +28,7 @@ Each scenario's `session.jsonl` is harvested from a real run. `assistant/chunk` ``` { kind: 'chunks', chunks: StreamChunk[] } -| { kind: 'throw', chunks: StreamChunk[], message: string, code: string, status?: number } +| { kind: 'throw', chunks: StreamChunk[], message: string, code: string } | { kind: 'hang' } ``` diff --git a/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md index 45348cfd2e..5c9e1014b8 100644 --- a/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md +++ b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md @@ -6,7 +6,7 @@ Status: implemented Model-driving ACP snapshot scenarios ship both `session.jsonl` and `session.golden.jsonl`. For normal recorded scenarios, `session.jsonl` is the replay fixture harvested from a real run, and the replay test normalizes the newly persisted log and compares it to `session.golden.jsonl`. In the current fixtures, the normalized recorded log and normalized golden are identical for the ordinary recorded scenarios. -Authored override scenarios (`error-finish`, `cancel`) currently use `replay.override.json` to drive model behavior and keep `session.jsonl` as a minimal dummy fixture, while `session.golden.jsonl` holds the expected persisted log. The override file is a JSON array of `ReplayEntry` objects: `{ "kind": "chunks", "chunks": StreamChunk[] }`, `{ "kind": "throw", "chunks": StreamChunk[], "message": string, "code": string, "status"?: number }`, or `{ "kind": "hang" }`. That split is also unnecessary: when an override sidecar exists, `llm-replay` replaces the derived script and does not need `session.jsonl` for model chunks, so `session.jsonl` can still be the expected session-log artifact for the scenario. +Authored override scenarios (`error-finish`, `cancel`) currently use `replay.override.json` to drive model behavior and keep `session.jsonl` as a minimal dummy fixture, while `session.golden.jsonl` holds the expected persisted log. The override file is a JSON array of `ReplayEntry` objects: `{ "kind": "chunks", "chunks": StreamChunk[] }`, `{ "kind": "throw", "chunks": StreamChunk[], "message": string, "code": string }`, or `{ "kind": "hang" }`. That split is also unnecessary: when an override sidecar exists, `llm-replay` replaces the derived script and does not need `session.jsonl` for model chunks, so `session.jsonl` can still be the expected session-log artifact for the scenario. ## Decision diff --git a/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md b/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md index b2c39047b9..34bebd1194 100644 --- a/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md +++ b/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md @@ -17,7 +17,7 @@ Record two scenarios against the real API, both replayed keyless in the default ### Why a completed turn-1 is required -The fork backend seeds the child with the parent's **balanced completed-turn prefix** ([`completedTurnPrefix`](../../../../packages/subagent/subagent-fork)). A parent that forks on its very first turn has no completed turn to inherit, so the seed is empty (≡ a fresh spawn, `seedLength` 0) — which would NOT exercise the slice. Both scenarios therefore use a two-prompt input: the first prompt completes a turn (establishing a codeword the child is later asked to recall), the second delegates the fork. The recalled codeword in the child's transcript is incidental to the model's behavior; the load-bearing artifact is the child fixture's recorded `seedLength`, which the replay slice consumes. +The fork backend seeds the child with the parent's **balanced completed-turn prefix**. A parent that forks on its very first turn has no completed turn to inherit, so the seed is empty (≡ a fresh spawn, `seedLength` 0) — which would NOT exercise the slice. Both scenarios therefore use a two-prompt input: the first prompt completes a turn (establishing a codeword the child is later asked to recall), the second delegates the fork. The recalled codeword in the child's transcript is incidental to the model's behavior; the load-bearing artifact is the child fixture's recorded `seedLength`, which the replay slice consumes. ## Consequences diff --git a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md index edfcc18585..fd87377fb9 100644 --- a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md +++ b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md @@ -29,6 +29,8 @@ Thirteen scenarios under `examples/acp-agent/tests/snapshots/`, naming `hook-.jsonl` siblings are its ordered primary/child inventory, so the scenario table declares policy without duplicating a child count. The pinned-header contract ([pinned-header RFC](2026-07-06-pin-request-header-content-in-one-scenario.md)) is per-suite: each header class flags exactly one `pinsHeader` scenario, whose `system-prompt.golden.md` and JSONL tool list split the composed header into reviewable artifacts; the uniformity guard compares both against every live header in that class. A pinning scenario declares any legitimate changed-header count, and its Markdown artifact records every full changed prompt. The pure helpers (`sessionFixtureNames`, `fixtureContext`, `normalizedHeaders`, `normalizedSystemPrompts`, `formatSystemPromptSnapshot`, `headerChangeCount`) are exported from the module for direct unit coverage. ## Alternatives considered @@ -29,8 +31,8 @@ The machinery lives in [`packages/support/acp-snapshot`](../../../../packages/su ## Testing -Extraction preserved every existing ACP golden byte. The package's `src/` has per-file 100% coverage through a scripted ACP subprocess: harness tests cover every step operation, both expected-error branches, permission selection/fallback/impossible choice, environment forwarding, workspace seeding, and harvest ordering/noise/fallback; suite tests execute replay against committed synthetic fixtures and record against a temporary copy, plus the pure helpers. Two structurally unreachable guards retain reasoned coverage exclusions. The fake agent substitutes the `session/new` cwd into logs, including Darwin's `/var` realpath behavior, matching the real bin. +Extraction parity was proven mechanically: after the move, `pnpm run test:snapshot` matched the base commit's result with zero byte changes under `examples/acp-agent/tests/snapshots/`. The package's `src/` holds per-file 100% statements/branches/functions/lines under the gating unit run, driven through the real launcher by a scripted fake ACP bin (`tests/fixtures/fake-acp-agent.ts`, behavior scripted per scenario via a `behavior.json` beside the fixture): `harness.spec.ts` directly covers launcher defaults, captures, update waiting, shutdown, and environment/config variants, then covers every scenario step op, both expect-error arms, the permission queue (selection, fallback, impossible-click), workspace seeding, and the harvest ordering/noise/fallback branches; `suite.spec.ts` runs the factory for real at collection time — a replay suite over committed synthetic fixtures and a record suite over a temp copy (write-back never touches the committed tree; `ACP_SNAPSHOT_SPEC_BOOTSTRAP=1` re-bootstraps it) — plus direct cases for the pure helpers. The fake bin substitutes the `session/new` cwd, not `process.cwd()`, into scripted logs, matching what the real bin's header carries (darwin realpaths `/var/folders/…` to `/private/var/folders/…`). ## Consequences -A new example gets the whole snapshot tier from a scenario table plus fixtures — the sandbox branch merges master down and adds its own suite (own pin scenario, own overlay, fixtures via `test:snapshot:record`, approvals via `permissionAnswers`). The costs: `suite.ts` imports vitest, so the package is importable only inside a vitest run — a shape no other package has, stated in its README; each suite pins its own ~8 KB header fixture (a genuinely distinct composition deserves its own pin; an identical one would be caught by that suite's uniformity guard); and the e2e launcher duplication remains (`TODO(acp-test-harness)`) — the harness is the extraction target when that migration lands. +A new example gets the whole snapshot tier from a scenario table plus fixtures, while an ordinary ACP e2e gets the same tested process/client boundary from one launcher call. The costs: `suite.ts` imports vitest, so the package entry is importable only inside a vitest run — a shape no other package has, stated in its README; and each suite pins its own ~8 KB header fixture (a genuinely distinct composition deserves its own pin; an identical one would be caught by that suite's uniformity guard). diff --git a/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md index bf4e85e020..c05ae8d67f 100644 --- a/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md +++ b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md @@ -4,7 +4,7 @@ Status: proposed ## Problem -Add isolated subagent providers for Claude Code and Codex. The existing [named-provider seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) and [ACP backend](../../implemented/feature/2026-06-22-acp-subagent-backend.md) establish the process-boundary shape. A harness turn should be able to delegate a self-contained task to either product and receive its final answer without exposing parent secrets or inheriting host configuration from `~/.claude` or `~/.codex`. +The subagent seam ([the seam RFC](../../implemented/feature/2026-06-21-subagent-capability-seam.md)) hosts multiple named providers on `ctx.subagents`, and the ACP backend ([the ACP backend RFC](../../implemented/feature/2026-06-22-acp-subagent-backend.md)) proved the seam generalizes across a process boundary; its Future-providers section explicitly named the Codex app-server and the Claude Code Agent SDK as mechanically similar siblings. Those two are the engines actually worth delegating to today: a harness turn should be able to hand a self-contained task to a real Claude Code or a real Codex — a separate product with its own model, tools, and sandbox — and get back one final answer, without the parent deployment leaking its secrets into the child or the child's behavior silently depending on whatever `~/.claude` / `~/.codex` state exists on the host machine. ## Proposal @@ -12,9 +12,9 @@ Two sibling provider packages, structural variants of the ACP backend, plus one - `@deepseek-ai/dsh-subagent-claude-code` — drives a Claude Code child through `@anthropic-ai/claude-agent-sdk`'s `query()` (the SDK runs in the parent process and spawns its bundled `claude` CLI as the subprocess). Provider name `claude-code`: the child is the Claude Code *product*, not an Anthropic model adapter — "claude" stays reserved for a future `dsh-llm` adapter. - `@deepseek-ai/dsh-subagent-codex` — spawns `codex app-server` and drives one thread/turn over its JSON-RPC-over-stdio protocol with a hand-rolled newline-JSON client (~200–300 lines) in the package. -- `@deepseek-ai/dsh-subagent-process` — a pure library (the `subagent-inprocess` precedent) extracting what `dsh-subagent-acp` already carries and both new backends need: the credential env scrub (`SENSITIVE_ENV_PATTERN`/`buildChildEnv`), the EOF → SIGTERM → SIGKILL dispose ladder, and new isolated-config-dir helpers (`mkdtemp` create, best-effort remove). The ACP backend migrates onto it; `bash-local`'s sibling copy is left alone to bound the change. +- `@deepseek-ai/dsh-subagent-process` — a pure library (the `subagent-inprocess` precedent) extracting what `dsh-subagent-acp` already carries and both new backends need: the credential env scrub (`buildChildEnv`), the EOF → SIGTERM → SIGKILL dispose ladder, and new isolated-config-dir helpers (`mkdtemp` create, best-effort remove). The ACP backend migrates onto it; `bash-local`'s sibling copy is left alone to bound the change. -Both providers follow the ACP backend contract: a fresh child per `start`, one prompt round-trip, no inherited parent context or advertised optional capabilities, ignored `request.parent` and `request.agentOptions`, and a random branded agent id. `result` never rejects; child failures map to stop reasons while the original error reaches the logger. Each mounts `dsh-tool-subagent` under a distinct tool name. The tool result is the only new model-visible artifact, so no new session event is required; workspace mutations remain ambient side effects outside transcript replay. +Both providers copy the ACP backend's seam posture verbatim: fresh child per `start`, exactly one prompt round-trip, capabilities all `false`, `inheritsParentContext: false`, `request.parent`/`request.agentOptions` ignored, `id = SessionId(randomUUID())`, `result` never rejects — child-level failure flattens to a stop reason and the original error goes to `ctx.logger` via an `onError` spec callback. Model exposure is zero new code: `dsh-tool-subagent` is loaded once per provider with a distinct `toolName` (`subagent_claude_code`, `subagent_codex`). No new session events are needed — the only model-visible artifact is the tool result, so reconstructability holds exactly as it did for ACP. To be explicit about the boundary: the session log reconstructs the model-visible transcript, not workspace mutation history — a child granted write access mutates files as an ambient side effect outside the log, exactly as the bash tools and the ACP backend already do; replay reproduces requests, not the disk. ## Verified interface facts (pinned versions) @@ -31,11 +31,11 @@ Both integration surfaces were verified against pinned implementations before th ## Isolation and credentials -Authentication is API-key-only. Each run uses a fresh config directory (`CLAUDE_CONFIG_DIR` with `settingSources: []`, or `CODEX_HOME`) that is removed best-effort on dispose; config may instead select a persistent directory. The shared child-env helper forwards ordinary values such as `PATH`, `HOME`, `TMPDIR`, locale, and proxy settings, removes credential-shaped names, and overlays explicit `config.env`. Claude Code receives its API key through that overlay, while Codex receives it through `account/login/start` rather than a hand-written auth file. +Deployments authenticate with API keys only, and the child must not see the host user's Claude Code / Codex configuration: behavior has to be a function of `cordis.yml` alone. Each run gets a fresh `mkdtemp` config dir — `CLAUDE_CONFIG_DIR` for Claude Code (paired with an explicit `settingSources: []`), `CODEX_HOME` for Codex — removed best-effort on dispose; a config field can pin a persistent dir instead. The child env reuses the ACP backend's `buildChildEnv` semantics verbatim via the extraction: the ambient env is forwarded MINUS credential-shaped vars (`/KEY|SECRET|TOKEN/i`), with `config.env` layered on top — so `PATH`, `HOME`, `TMPDIR`, locale, and proxy vars survive and the CLIs run normally, while only credential-shaped ambient vars are scrubbed (`ANTHROPIC_API_KEY` enters explicitly through `config.env` for Claude Code), and the Codex key travels via the `account/login/start` RPC into the isolated `CODEX_HOME` rather than a hand-written `auth.json`. ## Permission and approval policy -Each backend exposes its engine's native policy vocabulary. Claude Code defaults to `permissionMode: default` with `permission: reject`; Codex defaults to `sandboxMode: read-only`, `approvalPolicy: never`, and the same rejected fallback. Examples opt into `acceptEdits` or `workspace-write`. Known approval, user-input, and elicitation requests receive the configured answer; unknown methods receive method-not-found and unknown notifications are consumed. No prompt reaches a human, and no child can wait indefinitely for unavailable input. +Instead of collapsing to ACP's single `permission: allow|reject` knob, each backend exposes its engine's native vocabulary as config, with conservative defaults: Claude Code gets `permissionMode` (default `default`) plus `permission: allow|reject` (default `reject`) as the `canUseTool` auto-answer for whatever falls through; Codex gets `sandboxMode` (default `read-only`) and `approvalPolicy` (default `never`) plus the same `permission` fallback for approval requests that still arrive. Defaults are deliberately do-no-harm (the out-of-box child cannot write files); examples demonstrate opening up (`acceptEdits` / `workspace-write`). The mechanical rule: EVERY server-initiated request is settled programmatically and promptly — the enumerated approval/user-input/elicitation requests by the configured policy, an unknown request method with a JSON-RPC method-not-found error response (never left pending), unknown notifications consumed — so no child request can wedge a turn waiting on an answer that will never come. Prompts never reach a human in this cut, matching ACP. ## StopReason mapping @@ -45,11 +45,11 @@ Liveness posture, stated explicitly: teardown timing is config, turn duration is ## Testing -Coverage is required at each applicable tier: +Named at every tier per the root AGENTS.md rule, and de-risked up front: -- **Keyless unit/integration:** drive a fake Claude CLI through the real SDK and a scripted Codex app-server through the real wire client. At per-file 100% coverage, exercise round trips, every stop mapping, both cancellation paths and pre-abort, permission policies, unknown messages, spawn failure, reload cleanup, export shape, scrubbed environments, temporary-directory removal, and Codex auth precheck failure. -- **With-key e2e:** each real engine performs file work under `acceptEdits` or `workspace-write`; skips name the missing binary or key and assert no child process remains. -- **Snapshot:** deferred as `TODO(claude-code-subagent-replay)` and `TODO(codex-subagent-replay)` pending the process-specific replay shape described by the [subagent replay RFC](../../implemented/testing/2026-06-22-subagent-snapshot-replay.md). +- **Keyless unit/integration**, mirroring the ACP spec list per backend (round-trip and output accumulation, every stop mapping, both cancel paths, already-aborted, permission auto-answer under both policies, unknown-message tolerance, bad-command spawn failure, HMR provider cleanup, export shape, isolation assertions on child env and temp-dir removal; Codex adds the auth-precheck failure path). Claude Code's harness is a scripted fake `claude` executable behind `pathToClaudeCodeExecutable` driven by the REAL SDK — a spike already passed end-to-end keyless in 24ms (the fake CLI answers one `control_request/initialize` and speaks plain stream-json, ~40 lines). Codex's harness is a scripted mock app-server subprocess speaking the verified wire protocol, the `mock-acp-server.ts` shape. +- **With-key e2e** per backend: the real engine does real file work verified on disk, under a pinned opened-up config so acceptance and the do-no-harm defaults don't collide — `permissionMode: 'acceptEdits'` for Claude Code, `sandboxMode: 'workspace-write'` + `approvalPolicy: 'never'` for Codex; self-skips report exactly what is missing (binary vs key). CI has no secrets, so these run locally per the with-key policy. +- **Snapshot**: deferred as `TODO(claude-code-subagent-replay)` / `TODO(codex-subagent-replay)` — the same distinct replay shape the ACP backend deferred ([the per-session replay RFC](../../implemented/testing/2026-06-22-subagent-snapshot-replay.md)); the keyless suites carry deterministic coverage meanwhile. ## Alternatives considered diff --git a/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md index c4ee6161bd..8e4f4cce13 100644 --- a/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md +++ b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md @@ -4,7 +4,7 @@ Status: proposed ## Problem -Package and gate inventories are repeated across TypeScript project references, package docs, CI prose, Knip overrides, and snapshot scenario metadata. Most restate package layout, manifest data, aggregate command contents, or fixture files. Each new package or scenario therefore creates avoidable synchronization points. +Package and gate inventories are repeated across TypeScript project references, package docs, CI prose, and Knip overrides. Most restate package layout, manifest data, or aggregate command contents. Each new package therefore creates avoidable synchronization points. The [package hierarchy](../../implemented/architecture/2026-06-20-package-hierarchy.md) already removed several of these by hand: `scripts/publint-all.ts` now derives its list from the `packages//` layout, and the two `tsconfig` `paths` maps collapsed to one `@deepseek-ai/dsh-*` wildcard. What remains is the inventory that cannot be globbed away — chiefly `tsconfig.build.json`'s project `references`, which TypeScript requires as an explicit array (no wildcard form). @@ -16,7 +16,7 @@ Make the remaining package/gate inventories discoverable. A single canonical sou The hierarchy does not need to encode every fact about a package, but it should encode the broad maintenance policy: core/product packages, integrations, capability seams, and support/test/example packages should not all require a hand-maintained exception list before scripts can tell them apart. -Two of the cataloged items need no generator at all: folding the e2e entry glob into knip's default stanza deletes the per-package restatements outright, and `childSessions` can be discovered from each scenario's fixture directory, leaving the scenario table to declare only policy (`recorded`, `hasModelTurn`, `comparesLog`) — and even those track fixture-derivable facts today (`comparesLog` ⟺ the committed log has entries beyond its header line; `recorded` ⟺ `hasModelTurn` with no `replay.override.json` sibling), so each new scenario class keeps adding knobs the fixture directory already answers. +One cataloged item needs no generator at all: folding the e2e entry glob into knip's default stanza deletes the per-package restatements outright. ## Acceptance criteria @@ -25,7 +25,6 @@ Two of the cataloged items need no generator at all: folding the e2e entry glob - Docs describe the source of truth rather than repeating generated inventories. - CI invokes the aggregate commands and lets those commands own their sub-gate lists. - `knip.json` carries a per-package override only where it encodes real information (an extra entry file, an ignored dependency), never a restatement of the default stanza. -- Snapshot scenarios declare policy, not facts discoverable from their fixture directories. ## Risks diff --git a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md deleted file mode 100644 index d94ce11f5c..0000000000 --- a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md +++ /dev/null @@ -1,40 +0,0 @@ -# RFC: Unify the agent id and the session id - -Status: proposed - -## Problem - -The agent factory carries two ids for each live agent/session pair: `agentId`, the `AgentRegistry` routing handle, and `sessionId`, the event-sourced and persisted-log identity. `CreateAgentOptions` takes both; `ResumeAgentOptions` takes `agentId` plus `resumeSessionId`; in-process subagents mint two independent UUIDs despite recording lineage separately. - -ACP already uses the same value for both identities. They diverge for config-created agents, resumed sessions, and in-process children, but no production path reattaches one live agent to several sessions or drives one session through several agent ids. Stdio keeps `labelBySession` only to recover an agent label from session events, and hooks expose both values for authors to reconcile. - -The [agent-scope runtime](../../implemented/architecture/2026-07-12-agent-scope-runtime-design.md) has no identity-specific reservation state: create and resume use one `AgentCreationTransaction`, and both registry entries use the same final-entry collision rule. Separate ids do not duplicate liveness, rollback, or quiescence machinery. Unification deletes one caller-supplied id, one UUID per in-process child, and the remaining translation paths without changing the transaction lifecycle; it also makes the live-agent registry enforce the session identity used by background-task ownership. - -`Session` separately exposes `Session.id` and `Session.header.id` even though construction requires them to match. The durable boundary must validate the duplicate, and consumers must choose between two homes for one fact. - -## Proposal - -Use one id for the agent registry entry and `session.header.id`. `CreateAgentOptions` accepts one identity for both final entries; resume registers the agent under the resumed session id; subagent creation mints one combined id; and `Session` keeps one identity home. Preserve the current transaction, final-entry collision checks, exact-entry detach, rollback, and quiescence; remove only maps and fields whose sole job is translating between the ids. - -The config-driven path must first settle its resume-or-create policy. Today it uses a stable agent label and a fresh UUID-suffixed session id to avoid colliding with an existing durable log on the next run. Under unification it must deliberately resume a fixed id, mint a fresh combined id, or expose that policy; implementation must not choose silently. - -`agent/created` and `agent/disposed` remain outside this proposal. They are publication lifecycle events rather than identity aliases; removing them requires a separate production-consumer audit and decision. - -## Alternatives considered - -**Keep separate routing and log identities.** A stable configured agent label paired with a fresh conversation is a real use of the distinction. If that display or routing identity is required, reject this proposal and enforce session-id uniqueness explicitly instead of hiding the translation in another map. - -## Acceptance criteria - -- Agent create/resume and subagent creation carry one identity; `Session` stores it in one place. -- The creation transaction retains final-entry collision, exact-entry detach, rollback, and quiescence guarantees without identity-specific lifecycle state. -- ACP, stdio, hooks, bash ownership, persistence, and lineage need no agent/session-id translation. -- The config-driven resume-or-create policy is explicit and covered across a durable restart. -- `agent/created` and `agent/disposed` change only after a separate production-consumer audit. -- Typecheck, coverage, snapshots, doc-sync, module-graph verification, build, and hygiene pass. - -## Risks - -Unification forecloses a stable actor identity spanning several session logs, including a future handoff or fork that preserves the actor while changing the session. Reintroducing that design would require a new explicit actor identity. It also makes a persisted, possibly client-chosen session id the registry handle and changes every create/resume call site and fixture. - -The config restart policy is the blocking design decision: a fixed combined id may collide with its existing log, while a per-run id gives up the stable configured label. If either independent actor identity or the stable-label/fresh-session pairing is required, reject this proposal and retain the separate ids with an explicit uniqueness guard. diff --git a/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md index cca7c57b34..f874784a6d 100644 --- a/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md +++ b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md @@ -25,6 +25,7 @@ The production corpus is `packages/*/*/src`, example sources/config, and runtime | `CompactionResult.startSeq`, `summarySeq`, `endSeq`, and `summary` | The production consumer reads only shadowed range/seq/token accounting; the durable log owns summary and event identity. | Remove the four result echoes while keeping both shared transcript renderers. | | `BasicCompactService` estimation/summarization visibility | No outside production caller invokes the five methods; the implemented RFC names only `estimateContentTokens()` and `summarize()` as subclass hooks. | Make those two `protected` and the three orchestration-only estimators private. | | `CodeLogEntry.source`/`level` and `RunCodeMeta.dispatches` | Every production consumer maps logs to text; no presenter/model path reads the other fields or the persisted dispatch count. | Make code-runtime logs strings (or text-only entries) and remove result-meta dispatch plumbing; keep the local counter that mints deterministic dispatch ids. | +| `CodeRuntime.language` and `CodeRuntime.isolation` | The worker backend supplies the only production values, while Code Mode and every other production caller invoke only `run()`. | Remove the unread descriptors while preserving the worker's language, isolation, budgets, cancellation, and disposal behavior. | | `ToolNotFoundError.toolName`, `SystemPrompt.config`, and `BashTask.command` | Each stored public value has no production reader. | Drop the unread field while retaining error messages, resolved configuration behavior, and task lifecycle. | | Backend package-root implementation helpers | The exact inventory below is called only through relative same-package imports. Production namespace imports mount the retained plugin contract without reading these properties; named root consumers are tests. | Retain each adapter/provider/service and its config/error contract; stop exporting the listed helper functions/constants at package roots. | | Consumer package-root implementation helpers | The exact inventory below has only same-package production callers. Production namespace imports mount plugin contracts without reading helper properties; named root consumers are tests. | Retain plugin contracts and stable error codes; move tests to package-local modules or public behavior and stop exporting the listed helpers at package roots. | diff --git a/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.i18n.yaml b/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.i18n.yaml new file mode 100644 index 0000000000..e04aed96a6 --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-19-make-jsonrpc-directional.md: 2a9579c53c111887e9d93cf02cc832304a496795 +2026-07-19-make-jsonrpc-directional.zh.md: 4f3793f1b4de3b4f69c4219c10fe5b3b360c8692 diff --git a/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.md b/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.md new file mode 100644 index 0000000000..2a9579c53c --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.md @@ -0,0 +1,46 @@ +# RFC: Make JSON-RPC completion and transport directional + +Status: proposed + +English | [中文](2026-07-19-make-jsonrpc-directional.zh.md) + +## Problem + +The JSON-RPC bridge models both endpoints as symmetric peers although the shipped protocol is directional. The TypeScript server accepts requests and emits responses or notifications, but its transport also implements unused outbound requests and inbound notification dispatch. The Python SDK sends requests and receives responses or notifications, but it also queues unused inbound server requests and exposes response helpers. + +`session/prompt` also reports one settled turn through two protocol shapes. The server emits `session.finished` and then returns the constant `{ accepted: true }`; the Python SDK discards that response and waits for the notification to recover the status. Because the response is written only after the handler returns, the notification necessarily precedes the constant response on the same stream. + +The unused halves add pending-request maps, generated IDs, request queues, close-time rejection paths, response helpers, and a second completion waiter without serving a production caller. + +## Proposal + +Specialize each endpoint to its actual role. The TypeScript transport will retain inbound requests, outbound responses, and outbound notifications. The Python client will retain outbound requests and inbound responses or notifications. Delete the opposite-direction request machinery from each side. + +Return the settled outcome directly from `session/prompt` as `{ status, reason }` after `agent.whenIdle()`. Delete `session.finished`, the constant acceptance response, and the Python post-response completion loop. `session.event` and subagent notifications still stream before the response, and durable session events remain the source for final-response reconstruction. + +## Implementation plan + +1. In `packages/ui/jsonrpc/src/server.ts`, replace `SessionPromptResult.accepted` with `status: 'ok' | 'error' | 'aborted'` and the captured `TurnEndReason`. `HarnessSdkServer.prompt()` will return `completed` as `ok`, `aborted` as `aborted`, and every other current or merge-extensible reason as `error`; reaching idle without a `turn/end` remains an invariant error. Remove only `session.finished`, leaving `session.event`, `subagent.started`, and `subagent.finished` unchanged. +2. In `packages/ui/jsonrpc/src/transport.ts`, replace `JsonRpcTransportPeer` with a server-side notification surface and retain `onRequest()`, `notify()`, `start()`, `flush()`, and `close()`. Remove generated request IDs, the pending-response map, outbound `request()`, inbound response and notification dispatch, and close-time pending-request rejection. Incoming response- and notification-shaped frames will be ignored, while request result, method-not-found, and handler-error responses retain their current behavior and remain ordered after notifications emitted by the awaited handler. +3. In `python/sdk/src/deepseek_harness/client.py`, `models.py`, and `__init__.py`, remove `IncomingRequest`, `_requests`, `notify()`, `next_request()`, `respond()`, and `respond_error()`. Add a public validated `SessionPromptResponse` carrying status and reason, return it from `session_prompt()`, and keep an explicit reader guard that ignores unexpected server-request frames instead of allowing them to match a response waiter. +4. In `python/sdk/src/deepseek_harness/api.py`, build `TurnResult.status` and a new `TurnResult.reason` from `SessionPromptResponse`, then delete the `session.finished` branch and second completion loop. Keep the subscription open during the request and preserve `_request_raw()`'s final notification drain so the last `turn/end` event and any subagent notification written before the response are collected before `Session.run()` reconstructs the final assistant message. +5. Replace the symmetric transport-pair cases in `packages/ui/jsonrpc/tests/transport.spec.ts` with raw client-input/server-output coverage, and update `server.spec.ts`, `plugin-apply.spec.ts`, and `built-scope-carrier.e2e.ts` for direct outcomes, ordering, overlap, shutdown, and the narrowed fake. Update `python/sdk/tests/test_client.py` for response-based settlement, unexpected-request-frame handling, callback and concurrency behavior, and the removed public helpers. Update the JSON-RPC and bilingual Python SDK READMEs, export JSDoc and declarations, `scripts/smoke-python-runtime.py`, and the Python single-executable snapshot. + +## Alternatives considered + +**Keep a generic symmetric JSON-RPC peer for future methods.** Server-initiated requests may eventually support interactive permissions, but no typed method or production consumer exists. The pre-release protocol can add the smallest required direction when that feature is designed instead of carrying an unexercised peer today. + +**Keep `session.finished` for streaming clients.** Turn settlement is not incremental data: the request response already marks the same boundary and follows all earlier notifications on the ordered stream. A second terminal notification creates two representations that clients must reconcile. + +## Acceptance criteria + +- The TypeScript endpoint cannot originate requests or consume notifications. +- The Python endpoint cannot originate notifications or consume server requests. +- `session/prompt` returns the authoritative `ok`, `error`, or `aborted` outcome and reason after turn settlement. +- Session events and subagent lifecycle notifications emitted during the turn arrive before the response. +- Same-session overlap rejection, framing, multibyte input, handler errors, flush, shutdown ordering, and final-response reconstruction retain their behavior. +- TypeScript bridge tests, Python SDK tests, built JSON-RPC coverage, snapshots, and generated API documentation pass. + +## Risks + +This deliberately narrows the pre-release wire protocol. Raw clients listening only for `session.finished`, or embedders using the unused symmetric transport methods, must move to the prompt response. A future server-initiated request requires a new typed protocol addition rather than reusing generic dormant machinery. diff --git a/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.zh.md b/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.zh.md new file mode 100644 index 0000000000..4f3793f1b4 --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.zh.md @@ -0,0 +1,46 @@ +# RFC: 让 JSON-RPC 完成结果与传输方向单一化 + +Status: proposed + +[English](2026-07-19-make-jsonrpc-directional.md) | 中文 + +## 问题 + +JSON-RPC 桥接层把两个端点都建模为对称的对等端,但实际协议具有固定方向。TypeScript 服务端接收请求并发出响应或通知,其传输层却还实现了未使用的出站请求和入站通知分发。Python SDK 发送请求并接收响应或通知,却还会把未使用的服务端入站请求放入队列,并公开响应辅助方法。 + +`session/prompt` 还会用两种协议结构报告同一个已结束轮次。服务端先发出 `session.finished`,再返回常量 `{ accepted: true }`;Python SDK 丢弃该响应,转而等待通知以取得状态。响应只有在处理函数返回后才会写入,因此在同一条有序流上,通知必然先于这个常量响应。 + +这些未使用的双向能力引入了待处理请求表、生成 ID、请求队列、关闭时的拒绝路径、响应辅助方法和第二套完成等待逻辑,却没有任何生产调用方使用。 + +## 提案 + +按实际角色收窄两个端点。TypeScript 传输层只保留入站请求、出站响应和出站通知。Python 客户端只保留出站请求以及入站响应或通知。删除两侧与实际方向相反的请求机制。 + +在 `agent.whenIdle()` 完成后,由 `session/prompt` 直接返回 `{ status, reason }` 作为轮次结果。删除 `session.finished`、常量接纳响应以及 Python 中响应后的完成等待循环。`session.event` 与 subagent 通知仍在响应前流式发出,持久会话事件仍是最终响应重建的真源。 + +## 实施计划 + +1. 在 `packages/ui/jsonrpc/src/server.ts` 中,用 `status: 'ok' | 'error' | 'aborted'` 和捕获的 `TurnEndReason` 替换 `SessionPromptResult.accepted`。`HarnessSdkServer.prompt()` 把 `completed` 映射为 `ok`,把 `aborted` 映射为 `aborted`,把其他当前或可合并扩展的原因映射为 `error`;进入空闲状态却没有 `turn/end` 仍视为不变量错误。只删除 `session.finished`,保持 `session.event`、`subagent.started` 和 `subagent.finished` 不变。 +2. 在 `packages/ui/jsonrpc/src/transport.ts` 中,用服务端通知接口替换 `JsonRpcTransportPeer`,并保留 `onRequest()`、`notify()`、`start()`、`flush()` 和 `close()`。删除生成的请求 ID、待处理响应表、出站 `request()`、入站响应与通知分发,以及关闭时对待处理请求的拒绝逻辑。入站响应结构和通知结构将被忽略;请求结果、方法不存在与处理器错误响应保持原有行为,并继续排在被等待处理器发出的通知之后。 +3. 在 `python/sdk/src/deepseek_harness/client.py`、`models.py` 和 `__init__.py` 中,删除 `IncomingRequest`、`_requests`、`notify()`、`next_request()`、`respond()` 和 `respond_error()`。新增公开且经过校验的 `SessionPromptResponse` 来携带状态与原因,由 `session_prompt()` 返回该对象,并保留明确的读取保护:忽略意外的服务端请求帧,避免它们命中响应等待器。 +4. 在 `python/sdk/src/deepseek_harness/api.py` 中,根据 `SessionPromptResponse` 构造 `TurnResult.status` 和新增的 `TurnResult.reason`,再删除 `session.finished` 分支与第二个完成循环。请求期间保持订阅打开,并保留 `_request_raw()` 最后的通知排空步骤,确保写在响应前的最后一条 `turn/end` 事件与任何 subagent 通知,都会在 `Session.run()` 重建最终助手消息之前被收集。 +5. 用原始客户端输入与服务端输出覆盖替换 `packages/ui/jsonrpc/tests/transport.spec.ts` 中的对称传输对用例,并更新 `server.spec.ts`、`plugin-apply.spec.ts` 和 `built-scope-carrier.e2e.ts`,覆盖直接结果、顺序、重叠、关闭和收窄后的伪实现。更新 `python/sdk/tests/test_client.py`,覆盖基于响应的结束流程、意外请求帧处理、回调与并发行为,以及已删除的公开辅助方法。同步更新 JSON-RPC README、双语 Python SDK README、导出 JSDoc 与声明、`scripts/smoke-python-runtime.py` 和 Python 单可执行文件快照。 + +## 备选方案 + +**为未来方法保留通用的对称 JSON-RPC 对等端。** 服务端发起的请求将来可能用于交互式权限,但当前没有类型化方法或生产消费方。该功能完成设计后,预发布协议可以增加所需的最小方向,无需提前保留未使用的对等端能力。 + +**为流式客户端保留 `session.finished`。** 轮次结束不是增量数据:请求响应已经标识同一个边界,并且在有序流中位于先前所有通知之后。第二条终止通知会产生两种结果表示,迫使客户端进行协调。 + +## 验收标准 + +- TypeScript 端点无法发起请求,也不消费通知。 +- Python 端点无法发起通知,也不消费服务端请求。 +- 轮次结束后,`session/prompt` 返回权威的 `ok`、`error` 或 `aborted` 状态及其原因。 +- 轮次中发出的会话事件与 subagent 生命周期通知都先于响应到达。 +- 同一会话的重叠拒绝、分帧、多字节输入、处理器错误、flush、关闭顺序与最终响应重建保持原有行为。 +- TypeScript 桥接测试、Python SDK 测试、构建后 JSON-RPC 覆盖、快照和生成的 API 文档全部通过。 + +## 风险 + +本提案会刻意收窄预发布协议格式。仅监听 `session.finished` 的原始客户端,以及使用未使用对称传输方法的嵌入方,都必须改为读取请求响应。未来若需要服务端发起请求,应新增类型化协议,而不是复用休眠的通用机制。 diff --git a/docs/rfc/proposed/simplification/2026-07-19-retire-subagent-mock-package.i18n.yaml b/docs/rfc/proposed/simplification/2026-07-19-retire-subagent-mock-package.i18n.yaml new file mode 100644 index 0000000000..37c3c8b508 --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-19-retire-subagent-mock-package.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-19-retire-subagent-mock-package.md: 2731d35448cbc4dc9db4c92579c35202a53eefdd +2026-07-19-retire-subagent-mock-package.zh.md: 919c56502227157465277cc07c23fb7096763684 diff --git a/docs/rfc/proposed/simplification/2026-07-19-retire-subagent-mock-package.md b/docs/rfc/proposed/simplification/2026-07-19-retire-subagent-mock-package.md new file mode 100644 index 0000000000..2731d35448 --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-19-retire-subagent-mock-package.md @@ -0,0 +1,35 @@ +# RFC: Retire the standalone subagent mock package + +Status: proposed + +English | [中文](2026-07-19-retire-subagent-mock-package.zh.md) + +## Problem + +`@deepseek-ai/dsh-subagent-mock` is a configurable test double packaged as a workspace plugin. Its only external consumers are the `tool-subagent` unit suite and the tool-catalog generator. No runtime package, example, snapshot configuration, or real provider loads it. + +That narrow fixture carries a manifest, exports, peer and development dependencies, project references, package README obligations, Loader composition tests, module-graph membership, and documentation-gate exceptions. The tool-catalog generator mounts it only to make the real subagent tool register its schema; it never executes a child. + +## Proposal + +Delete `packages/support/subagent-mock`. Move the scripted provider behavior actually used by `tool-subagent` into a package-local test fixture while continuing to exercise the real `SubagentService`, provider registry, and tool implementation. + +Have the tool-catalog generator register the minimal provider descriptor required before mounting `ToolSubagent`. Remove the package references, manifest dependency, graph node, README allowlists, and mock-specific Loader tests. + +## Alternatives considered + +**Keep a reusable mock package for future tests.** Reuse has not materialized outside one test file and one generator. A future second consumer can extract a fixture once its shared contract is known; packaging all configurable reply, cancellation, result, and Loader behavior today makes test infrastructure look like a supported backend. + +**Generate the subagent schema without mounting the real tool.** Hand-constructing or importing the schema would weaken the catalog's check that the production registry and tool composition expose the documented shape. The generator should keep mounting the real service and tool with only the child boundary replaced. + +## Acceptance criteria + +- `packages/support/subagent-mock` and every workspace, graph, dependency, and documentation entry for it are removed. +- `tool-subagent` tests retain every scripted reply, structured-result, cancellation, foreground/background, and task-integration case they currently exercise through the real service and tool. +- Tool-catalog generation mounts the production subagent registry and tool with a minimal local provider and produces a byte-identical catalog. +- No runtime or example package gains a dependency on test-only fixtures. +- Focused subagent tests, catalog and graph generation, module-graph verification, build, hygiene, and the full pre-push suite pass. + +## Risks + +Relocating the fixture could accidentally replace too much production composition with a stub. The local fixture must implement only the nondeterministic child boundary; capability checks, lifecycle, task handling, and tool output remain under production code. Mock Loader and HMR coverage can disappear because no deployed composition consumes the package afterward. diff --git a/docs/rfc/proposed/simplification/2026-07-19-retire-subagent-mock-package.zh.md b/docs/rfc/proposed/simplification/2026-07-19-retire-subagent-mock-package.zh.md new file mode 100644 index 0000000000..919c565022 --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-19-retire-subagent-mock-package.zh.md @@ -0,0 +1,35 @@ +# RFC: 撤销独立的 subagent mock 包 + +Status: proposed + +[English](2026-07-19-retire-subagent-mock-package.md) | 中文 + +## 问题 + +`@deepseek-ai/dsh-subagent-mock` 是一个以工作区插件形式发布的可配置测试替身。它仅有两个外部消费方:`tool-subagent` 单元测试和工具目录生成器。运行时包、示例、快照配置和真实提供方都不会加载它。 + +这个用途狭窄的 fixture(测试前置数据)需要维护 manifest(元数据清单)、导出、对等依赖(peer dependency)与开发依赖、项目引用、包(package)README 契约、Loader 组合测试、模块图成员关系以及文档门禁例外。工具目录生成器挂载它,只是为了让真实 subagent 工具注册 schema;生成器从不执行子 agent。 + +## 提案 + +删除 `packages/support/subagent-mock`。把 `tool-subagent` 实际使用的脚本化提供方行为移入该包的本地测试 fixture,同时继续测试真实的 `SubagentService`、提供方注册表和工具实现。 + +工具目录生成器在挂载 `ToolSubagent` 前,只注册所需的最小提供方描述。删除该包的项目引用、manifest 依赖、图节点、README 允许列表和 mock 专用 Loader 测试。 + +## 备选方案 + +**为未来测试保留可复用 mock 包。** 除一个测试文件和一个生成器外,复用需求并未出现。未来产生第二个消费方时,可以在共享契约明确后再提取 fixture;当前把所有可配置回复、取消、结果与 Loader 行为打包,会使测试基础设施看起来像受支持的后端。 + +**不挂载真实工具,直接生成 subagent schema。** 手工构造或直接导入 schema,会削弱目录生成器对生产注册表与工具组合是否公开文档结构的校验。生成器应继续挂载真实服务与工具,只替换不确定的子 agent 边界。 + +## 验收标准 + +- 删除 `packages/support/subagent-mock`,并移除其全部工作区、图、依赖和文档条目。 +- `tool-subagent` 测试保留当前通过真实服务与工具覆盖的全部脚本化回复、结构化结果、取消、前台与后台运行以及任务集成用例。 +- 工具目录生成器使用最小本地提供方挂载生产 subagent 注册表与工具,并生成字节级一致的目录。 +- 运行时包与示例包都不会新增对测试专用 fixture 的依赖。 +- 聚焦 subagent 测试、目录与图生成、模块图校验、构建、hygiene 和完整 pre-push 门禁全部通过。 + +## 风险 + +迁移 fixture 时,可能会误将过多生产组合替换成测试替身。本地 fixture 只能实现不确定的 subagent 边界;能力检查、生命周期、任务处理与工具输出仍由生产代码负责。由于之后不再有部署组合消费该包,可以删除 mock 的 Loader 与 HMR 覆盖。 diff --git a/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.i18n.yaml b/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.i18n.yaml new file mode 100644 index 0000000000..185d7af4ac --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-19-use-one-session-surface-manager.md: 0e4fa14b21f5054e1ace425712506c5c060f2251 +2026-07-19-use-one-session-surface-manager.zh.md: 26126310eefaf56aa45efc428a54e65f0cf35947 diff --git a/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.md b/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.md new file mode 100644 index 0000000000..0e4fa14b21 --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.md @@ -0,0 +1,43 @@ +# RFC: Use one surface manager per session + +Status: proposed + +English | [中文](2026-07-19-use-one-session-surface-manager.zh.md) + +## Problem + +`Session` maintains two `SurfaceManager` instances over the same append-only event log. `surfaceValidator` eagerly validates seed and append candidates, while the lazy `_surface` independently folds committed events for `session.surface`, derived messages, compaction, and workspace context. Once the public surface is read, every later event advances duplicate node and replacement-generation state. + +The [session surface decision](../../implemented/architecture/2026-06-18-session-surface.md) calls for one ordered surface and one representation to validate. The second manager does not create an independent authority or protect a different failure boundary; it repeats the canonical fold and gives the two views a state-drift opportunity. + +## Proposal + +Keep one `SurfaceManager` per `Session`. Seed and append acceptance continue to call `validateNext()` before committing an event, and the public surface view reads `nodes` and `replaceGeneration` from that same manager. + +Expose only the readonly surface contract from `Session.surface`; candidate validation remains owned by `Session`. Retain `foldSurface()` as the detached full-log replay function used by offline validation and reconstruction. + +## Implementation plan + +1. In `packages/core/session/src/surface.ts`, export a structural `SessionSurface` contract containing only readonly `nodes` and `replaceGeneration`, and make `SurfaceManager` implement it. Re-export that type from `packages/core/session/src/index.ts` so `Session.surface` no longer exposes `validateNext()` through its declaration. +2. In `Session`, replace `surfaceValidator` and lazy `_surface` with one eagerly constructed `surfaceManager`. Route seed and append validation through that manager and return it from `get surface(): SessionSurface`; `deriveMessages()` will read the same nodes and generation. `validateNext()` may synchronize already committed log entries, but it must only plan the uncommitted candidate. The candidate reaches manager state after `log.push()` and the next delta synchronization, so rejection by surface validation or pre-commit `internal/dispatch` cannot leave phantom state. +3. Keep `foldSurface()` and the transition functions in `surface.ts` unchanged. Compile and exercise the direct consumers in `packages/compact/compact/src/tool-pairing.ts`, `packages/compact/compact-basic/src/region.ts`, and `packages/context/workspace-context/src/state.ts`; they continue to consume only nodes and replacement generation. +4. Extend `packages/core/session/tests/surface.spec.ts` to read the public view before an invalid candidate, prove that nodes and generation remain at the accepted prefix after rejection, append a later valid event, and compare every resulting prefix with `foldSurface()`. Add an `internal/dispatch` veto case and a type-level `SessionSurface` assertion in `session.spec.ts`, while retaining the seeded replay, delta-growth, replacement, generation, and derived-cache cases. +5. Run the request-reconstruction, compaction tool-pairing, compaction range, and workspace-context regression suites that consume the surface. In the implementation PR, update `packages/core/session/README.md`, `docs/core-data-structures/session.md`, the implemented session-surface RFC and its Chinese counterpart, the translation record, `scripts/type-equiv.manifest.json`, and the generated RFC index before moving this RFC pair to `implemented/`. + +## Alternatives considered + +**Keep acceptance and projection state separate.** Separate instances appear to isolate public reads from validation, but ordinary callers already receive borrowed surface state and cannot mutate it through the declared readonly contract. A cast that mutates the returned node array already corrupts derived history; duplicating the manager is not a sound runtime trust boundary. + +**Recompute the public surface from the full log on every access.** This removes cached duplicate state but gives up incremental derivation and makes repeated request construction scale with complete session history. + +## Acceptance criteria + +- A live `Session` owns exactly one incremental `SurfaceManager`. +- Seed and append candidates are validated before publication with no partial surface mutation on rejection. +- `session.surface`, derived messages, compaction, and workspace context observe the same nodes and replacement generation as the acceptance path. +- `foldSurface()` remains available for detached replay and agrees with the live manager for every accepted prefix. +- Session surface, seed, request reconstruction, compaction tool-pairing, and workspace-context tests pass. + +## Risks + +Sharing one manager makes the readonly borrowed-state contract more important because a hostile cast could corrupt both validation and projection state. The implementation should return a narrowed view and keep mutation methods inaccessible through `Session.surface`; JavaScript callers that deliberately bypass the type contract remain outside the supported same-process boundary. diff --git a/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.zh.md b/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.zh.md new file mode 100644 index 0000000000..26126310ee --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.zh.md @@ -0,0 +1,43 @@ +# RFC: 每个会话只使用一个表层管理器 + +Status: proposed + +[English](2026-07-19-use-one-session-surface-manager.md) | 中文 + +## 问题 + +`Session` 针对同一份仅追加事件日志维护两个 `SurfaceManager` 实例。`surfaceValidator` 主动校验种子事件与追加候选事件,延迟创建的 `_surface` 则独立折叠已提交事件,供 `session.surface`、派生消息、压缩(compaction)和工作区上下文使用。一旦读取公共表层,之后的每个事件都会推进两份重复的节点状态与替换代数状态。 + +[会话表层决策](../../implemented/architecture/2026-06-18-session-surface.md)要求系统只保留一个有序表层,并使用一种表示完成校验。第二个管理器既不形成独立真源,也不保护不同的失败边界;它只会重复规范折叠,并使两个视图可能出现状态偏差。 + +## 提案 + +每个 `Session` 只保留一个 `SurfaceManager`。种子事件与追加事件的接纳流程仍在提交事件之前调用 `validateNext()`,公共表层视图则从同一个管理器读取 `nodes` 与 `replaceGeneration`。 + +`Session.surface` 只公开只读表层契约,候选事件校验仍由 `Session` 负责。保留 `foldSurface()`,用于离线校验与重建时执行分离的完整日志回放。 + +## 实施计划 + +1. 在 `packages/core/session/src/surface.ts` 中,导出结构化的 `SessionSurface` 契约,只包含只读的 `nodes` 与 `replaceGeneration`,并让 `SurfaceManager` 实现该契约。从 `packages/core/session/src/index.ts` 重新导出这个类型,使 `Session.surface` 的声明不再暴露 `validateNext()`。 +2. 在 `Session` 中,用一个主动创建的 `surfaceManager` 替换 `surfaceValidator` 与延迟创建的 `_surface`。种子事件与追加事件都通过该管理器校验,`get surface(): SessionSurface` 返回同一个对象,`deriveMessages()` 也读取同一份节点与代数。`validateNext()` 可以同步已提交的日志事件,但对尚未提交的候选事件只能制定变更计划。候选事件在 `log.push()` 之后、下一次增量同步时才进入管理器状态,因此表层校验拒绝或提交前 `internal/dispatch` 否决都不会留下虚假状态。 +3. 保持 `foldSurface()` 与 `surface.ts` 中的状态转换函数不变。编译并验证 `packages/compact/compact/src/tool-pairing.ts`、`packages/compact/compact-basic/src/region.ts` 和 `packages/context/workspace-context/src/state.ts` 中的直接消费方;它们仍然只读取节点与替换代数。 +4. 扩展 `packages/core/session/tests/surface.spec.ts`:先读取公共视图,再提交无效候选事件,证明拒绝后节点与代数仍停留在已接纳前缀;随后追加有效事件,并把每个结果前缀与 `foldSurface()` 比较。在 `session.spec.ts` 中新增 `internal/dispatch` 否决用例与类型层面的 `SessionSurface` 断言,同时保留种子回放、增量增长、替换、代数和派生缓存用例。 +5. 运行消费表层的请求重建、压缩工具配对、压缩范围与工作区上下文回归套件。在实现 PR 中,先更新 `packages/core/session/README.md`、`docs/core-data-structures/session.md`、已实现会话表层 RFC 及其中文对应文件、翻译记录、`scripts/type-equiv.manifest.json` 和生成的 RFC 索引,再把本 RFC 双语文件移入 `implemented/`。 + +## 备选方案 + +**继续分离接纳状态与投影视图。** 两个独立实例看似能够隔离公共读取和校验,但普通调用方目前取得的就是借用的表层状态,无法通过声明的只读契约修改它。通过类型断言修改返回的节点数组,本就会破坏派生历史;复制管理器并不能构成可靠的运行时信任边界。 + +**每次读取都根据完整日志重新计算公共表层。** 该方案不再缓存重复状态,但会放弃增量派生,使每次请求构造都随完整会话历史增长。 + +## 验收标准 + +- 每个活跃 `Session` 只拥有一个增量 `SurfaceManager`。 +- 种子事件与追加候选事件都在发布前完成校验,拒绝事件时不会留下只修改一半的表层状态。 +- `session.surface`、派生消息、压缩和工作区上下文观察到的节点与替换代数,和接纳路径使用的状态完全一致。 +- `foldSurface()` 仍可用于分离回放,并且对任意已接纳前缀都与活跃管理器一致。 +- 会话表层、种子、请求重建、压缩工具配对和工作区上下文测试全部通过。 + +## 风险 + +共享一个管理器会提高只读借用状态契约的重要性,因为恶意类型断言可能同时破坏校验状态和投影视图。实现应返回收窄后的视图,避免通过 `Session.surface` 暴露修改方法;刻意绕过类型契约的 JavaScript 调用方不属于受支持的同进程边界。 diff --git a/docs/rfc/rejected/simplification/2026-07-19-fold-compaction-package-split.i18n.yaml b/docs/rfc/rejected/simplification/2026-07-19-fold-compaction-package-split.i18n.yaml new file mode 100644 index 0000000000..d2a15cc2c2 --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-07-19-fold-compaction-package-split.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-19-fold-compaction-package-split.md: 7c7a2da85beb956f8d6c24f813fc33aa350b5c0e +2026-07-19-fold-compaction-package-split.zh.md: 37d75671d57226742608a525ea711b34780e65c6 diff --git a/docs/rfc/rejected/simplification/2026-07-19-fold-compaction-package-split.md b/docs/rfc/rejected/simplification/2026-07-19-fold-compaction-package-split.md new file mode 100644 index 0000000000..7c7a2da85b --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-07-19-fold-compaction-package-split.md @@ -0,0 +1,37 @@ +# RFC: Fold the single compaction backend into its service package + +Status: rejected — More compaction backends are planned, so the interface and basic implementation packages remain separate. + +English | [中文](2026-07-19-fold-compaction-package-split.zh.md) + +## Problem + +Compaction is split between `@deepseek-ai/dsh-compact`, which owns an abstract two-method service and shared types, and `@deepseek-ai/dsh-compact-basic`, which owns the only complete implementation. Shipped configurations load only the basic package, and no production package independently consumes the interface package except that implementation. + +The split adds a package manifest, README, project boundary, dependency edge, abstract forwarding class, generated catalog entries, and composition wiring without demonstrating backend substitution. The [capability-seam decision](../../implemented/architecture/2026-06-13-capability-seams.md) requires a real interface, implementation, and consumer rather than a preemptive split; the [compaction decision](../../implemented/feature/2026-06-18-compaction-capability-seam.md) records that its independent consumer was deferred. + +## Proposal + +Move the basic implementation into `@deepseek-ai/dsh-compact` and remove `@deepseek-ai/dsh-compact-basic`. Keep `ctx.compact`, `CompactionResult`, the shared transcript and tool-pairing helpers, the existing configuration, and the concrete compaction algorithm in one package. + +Preserve `summarize()` as a protected customization hook. A deployment-specific summarizer can subclass or intercept the existing LLM call without requiring a second capability package. Reintroduce an interface package only when a second complete backend and an independent consumer need substitution. + +Amend the implemented compaction decision and the [recallable-compaction proposal](../../proposed/feature/2026-07-06-recallable-compaction.md) if this proposal is accepted so package ownership has one durable description. + +## Alternatives considered + +**Keep the split because a remote or recall backend may arrive.** A possible future implementation does not justify the current package boundary. Recall adds a consumer of compaction results, not necessarily another implementation, and a remote summarizer can use the protected hook. + +**Move the implementation package name onto the interface package.** Keeping `compact-basic` as the surviving name would make the product service appear to be one optional backend. `compact` is the stable service identity already used by `ctx.compact` and is the clearer single-package owner. + +## Acceptance criteria + +- `@deepseek-ai/dsh-compact-basic` and its workspace/package metadata are removed. +- `@deepseek-ai/dsh-compact` owns the current configuration, plugin class, algorithm, types, events, and shared helpers. +- Existing deployments can load the surviving package with equivalent configuration and model-visible behavior. +- Automatic and manual compaction preserve cancellation, locking, token accounting, tool pairing, durable events, provenance, retry convergence, and transcript rendering. +- Loader composition, unit, runaway-turn, cancellation, snapshot, and real-model compaction tests pass; generated catalogs and module graphs are current. + +## Risks + +This is an intentional pre-release package-name contraction. Embedders loading `@deepseek-ai/dsh-compact-basic` must switch packages, and future backend substitution would require extracting a boundary again. The cost is acceptable only while one complete implementation exists; acceptance should be revisited if a second backend lands first. diff --git a/docs/rfc/rejected/simplification/2026-07-19-fold-compaction-package-split.zh.md b/docs/rfc/rejected/simplification/2026-07-19-fold-compaction-package-split.zh.md new file mode 100644 index 0000000000..37d75671d5 --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-07-19-fold-compaction-package-split.zh.md @@ -0,0 +1,37 @@ +# RFC: 将唯一的压缩后端并入服务包 + +Status: rejected — 计划增加更多压缩后端,因此接口包与 basic 实现包继续分离。 + +[English](2026-07-19-fold-compaction-package-split.md) | 中文 + +## 问题 + +压缩(compaction)目前拆分在两个包中:`@deepseek-ai/dsh-compact` 拥有一个含两个方法的抽象服务和共享类型,`@deepseek-ai/dsh-compact-basic` 拥有唯一的完整实现。交付配置只加载 basic 包,除了该实现外,没有生产包独立消费接口包。 + +该拆分增加了一份包(package)manifest(元数据清单)、README、项目边界、依赖边、抽象转发类、生成目录项和组合接线,却没有体现后端替换需求。[能力服务边界决策](../../implemented/architecture/2026-06-13-capability-seams.md)要求接口、实现和消费方都必须真实存在,而不能预先拆分;[压缩决策](../../implemented/feature/2026-06-18-compaction-capability-seam.md)也记录了独立消费方仍被推迟。 + +## 提案 + +把 basic 实现移入 `@deepseek-ai/dsh-compact`,并删除 `@deepseek-ai/dsh-compact-basic`。`ctx.compact`、`CompactionResult`、共享 transcript(文本记录)和工具配对辅助方法、现有配置以及具体压缩算法都由一个包负责。 + +保留 `summarize()` 作为受保护的自定义钩子。部署专用的摘要器可以通过继承或拦截现有 LLM(大语言模型)调用完成定制,无需第二个能力包。只有在第二个完整后端与独立消费方确实需要替换实现时,才重新提取接口包。 + +如果本提案获准,应同步修订已实现的压缩决策与[可回忆压缩提案](../../proposed/feature/2026-07-06-recallable-compaction.md),使包所有权只有一处持久说明。 + +## 备选方案 + +**为可能出现的远程或回忆后端保留拆分。** 一种可能的未来实现不足以支撑当前包边界。回忆功能会增加压缩结果的消费方,但不一定增加另一种实现;远程摘要器也可以使用受保护钩子。 + +**让接口包并入实现包名。** 如果保留 `compact-basic` 作为最终名称,产品服务会看起来像一个可选后端。`compact` 已经是 `ctx.compact` 使用的稳定服务标识,更适合作为单包所有者。 + +## 验收标准 + +- 删除 `@deepseek-ai/dsh-compact-basic` 及其工作区和包元数据。 +- `@deepseek-ai/dsh-compact` 拥有当前配置、插件类、算法、类型、事件和共享辅助方法。 +- 现有部署可以使用等效配置加载保留的包,模型可见行为不变。 +- 自动压缩和手动压缩保留取消、锁、token 用量、工具配对、持久事件、来源、重试收敛和 transcript 渲染行为。 +- Loader 组合、单元、失控轮次、取消、快照和真实模型压缩测试全部通过;生成目录与模块图保持最新。 + +## 风险 + +这是一项有意实施的预发布包名收缩。加载 `@deepseek-ai/dsh-compact-basic` 的嵌入方必须切换包,未来的后端替换也需要重新提取边界。只有在仍然只有一个完整实现时,这项代价才可接受;如果第二个后端先行落地,应重新评估是否接纳本提案。 diff --git a/docs/testing.md b/docs/testing.md index 8d4f0f554f..06d8fd4321 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -25,7 +25,7 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword - Product-visible plugins require a non-unit REAL-composition test. Hand-built `ctx.plugin(...)` suites are insufficient: boot test-only `cordis.yml` through Loader and app/process, mock only external/nondeterministic boundaries, and assert model-visible request/log, durable state, or user-visible output. Keep opt-ins out of shipped defaults. - A guard only guards if the regression actually fails it. For a plugin without `inject` (bundle/composition plugins), a Loader smoke stays green under a broken export shape — add an explicit `expect('default' in mod).toBe(false)` plus an `unwrapExports` round-trip assertion, and prove it: introduce the regression, watch red, revert. -- "Real entry path" means the published artifact: the package `bin` points at built `lib/bin.js` under plain `node`, which tsx masks (settle races, module resolution, a swallowed load failure exiting 0). The same applies to any non-index runtime entry the built package resolves at run time (the worker-thread runtime's sibling `lib/worker.cjs`). Keep the built-artifact smokes green (`packages/ui/*/tests/built-bin.e2e.ts`, `packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`), and assert a genuinely-missing config exits non-zero. +- "Real entry path" means the published artifact: a package `bin` runs built `lib/bin.js` under plain `node`, exposing failures tsx masks (settle races, module resolution, swallowed load failures). The same applies to non-index runtime entries (the worker-thread sibling `lib/worker.cjs`) and singleton modules shared across bundles (`packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts`). Keep the built-artifact smokes green (`packages/ui/*/tests/built-bin.e2e.ts`, `packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`), and assert a genuinely-missing config exits non-zero. ## Test subprocess launch modes diff --git a/examples/acp-agent/tests/acp.e2e.ts b/examples/acp-agent/tests/acp.e2e.ts index b19bc9bfb1..46592e4704 100644 --- a/examples/acp-agent/tests/acp.e2e.ts +++ b/examples/acp-agent/tests/acp.e2e.ts @@ -1,173 +1,62 @@ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' -import { Readable, Writable } from 'node:stream' -import { mkdtemp, rm, readFile } from 'node:fs/promises' +import { mkdtemp, readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { - ClientSideConnection, - ndJsonStream, - PROTOCOL_VERSION, - type Agent as AcpAgent, - type Client, - type RequestPermissionRequest, - type RequestPermissionResponse, - type SessionNotification, -} from '@agentclientprotocol/sdk' -import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' + launchAcpTestAgent, + type AgentUnderTest, + type LaunchedAcpTestAgent, +} from '@deepseek-ai/dsh-acp-snapshot' +import { cleanupAcpExampleTest } from './cleanup.ts' /** - * Boots examples/acp-agent as an ACP subprocess. The key-gated prompt leg - * verifies its filesystem effect; a keyless initialize leg verifies that stdout - * contains only framed JSON-RPC. Each subprocess is disposed in `afterEach`. + * End-to-end: boot examples/acp-agent as a real subprocess speaking ACP over + * its stdio, drive it with a real ClientSideConnection, send a real prompt, and + * verify the WORLD (a file the agent wrote), not the agent's self-report. Owns + * and disposes the subprocess in afterEach. Key-gated. + * + * Also asserts stdout purity (only framed JSON-RPC on stdout) — that one runs + * WITHOUT a key, since it only needs the server to boot and answer initialize. */ -// The child runs from a temp cwd, so its bin and config path are absolute. -const binScript = fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)) -const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -// The root tsconfig supplies unbuilt workspace `paths`; making it explicit -// avoids accidental resolution through stale built output. -const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) - -interface Spawned { - child: ChildProcessWithoutNullStreams - client: ClientSideConnection - updates: SessionNotification['update'][] - stderr: string[] +const AGENT: AgentUnderTest = { + binScript: fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)), + configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), + tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)), } +const DANGER_FULL_ACCESS_ENV = { DSH_PERMISSION_MODE: 'danger-full-access' } -function spawnAcpAgent(cwd: string, env: NodeJS.ProcessEnv = process.env): Spawned { - const launch = resolveExampleLaunch({ - srcBin: binScript, - configArgs: ['--config', configPath], - tsconfigPath: repoTsconfig, - env: { - DSH_PERMISSION_MODE: 'danger-full-access', - DSH_HOME: join(cwd, '.dsh'), - DSH_AGENTS_HOME: join(cwd, '.agents'), - }, - }) - const child = spawn( - launch.command, - launch.args, - { cwd, env: { ...env, ...launch.env }, stdio: ['pipe', 'pipe', 'pipe'] }, - ) - const stderr: string[] = [] - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => stderr.push(chunk)) - - const updates: SessionNotification['update'][] = [] - const stream = ndJsonStream( - Writable.toWeb(child.stdin) as WritableStream, - Readable.toWeb(child.stdout) as ReadableStream, - ) - const makeClient = (_agent: AcpAgent): Client => ({ - sessionUpdate(params: SessionNotification): Promise { - updates.push(params.update) - return Promise.resolve() - }, - requestPermission(_params: RequestPermissionRequest): Promise { - // This suite selects danger-full-access (approval never), so the bridge - // never prompts here; answer cancelled if an unexpected ask arrives. - return Promise.resolve({ outcome: { outcome: 'cancelled' } }) - }, - }) - const client = new ClientSideConnection(makeClient, stream) - return { child, client, updates, stderr } -} - -let spawned: Spawned | undefined +let spawned: LaunchedAcpTestAgent | undefined let workdir: string | undefined -function hasStdoutLine(out: string[]): boolean { - return out.join('').split('\n').some(line => line.trim().length > 0) -} - -async function waitForStdoutLine(child: ChildProcessWithoutNullStreams, out: string[], stderr: string[], timeoutMs: number): Promise { - await new Promise((resolve, reject) => { - const cleanup = () => { - clearTimeout(timeout) - child.stdout.off('data', onData) - child.off('exit', onExit) - child.off('error', onError) - } - const pass = () => { - cleanup() - resolve() - } - const fail = (reason: string) => { - cleanup() - reject(new Error(`${reason}; stderr: ${stderr.join('')}`)) - } - const onData = () => { - if (hasStdoutLine(out)) pass() - } - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - fail(`ACP child exited before emitting a stdout frame (code ${code ?? 'null'}, signal ${signal ?? 'null'})`) - } - const onError = (error: Error) => { - fail(`ACP child failed before emitting a stdout frame: ${error.message}`) - } - const timeout = setTimeout(() => { - fail(`ACP child did not emit a stdout frame within ${timeoutMs}ms`) - }, timeoutMs) - - child.stdout.on('data', onData) - child.on('exit', onExit) - child.on('error', onError) - onData() - }) -} - afterEach(async () => { - if (spawned) { - spawned.child.kill('SIGKILL') - spawned = undefined - } - if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + const ownedSpawned = spawned + const ownedWorkdir = workdir + spawned = undefined workdir = undefined + await cleanupAcpExampleTest(ownedSpawned, ownedWorkdir) }) describe('acp-agent over real stdio (no key required)', () => { it('emits only framed JSON-RPC on stdout', async () => { workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-')) - // Collect raw stdout bytes directly (bypass the SDK framing) to inspect. - // A dummy key boots the adapter; this purity test sends no prompt and makes no model call. - const launch = resolveExampleLaunch({ - srcBin: binScript, - configArgs: ['--config', configPath], - tsconfigPath: repoTsconfig, + // Inspect the launcher's raw-byte tee in addition to driving its SDK client. + // A dummy key lets the deepseek adapter APPLY (it only checks the key is + // present at boot, not valid — the key is used only on a real model call, + // which this purity test never triggers). So this runs WITHOUT real creds. + spawned = launchAcpTestAgent({ + agent: AGENT, + cwd: workdir, env: { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot', - DSH_PERMISSION_MODE: 'danger-full-access', - DSH_HOME: join(workdir, '.dsh'), - DSH_AGENTS_HOME: join(workdir, '.agents'), + ...DANGER_FULL_ACCESS_ENV, }, }) - const child = spawn(launch.command, launch.args, { - cwd: workdir, - env: { ...process.env, ...launch.env }, - stdio: ['pipe', 'pipe', 'pipe'], - }) - const out: string[] = [] - const stderr: string[] = [] - child.stdout.setEncoding('utf8') - child.stderr.setEncoding('utf8') - child.stdout.on('data', (c: string) => out.push(c)) - child.stderr.on('data', (c: string) => stderr.push(c)) + await spawned.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) - // Send a single initialize request as a newline-delimited JSON-RPC frame. - const req = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} } }) - child.stdin.write(req + '\n') - - try { - await waitForStdoutLine(child, out, stderr, 15_000) - } finally { - child.kill('SIGKILL') - } - - const lines = out.join('').split('\n').filter(l => l.trim().length > 0) + const lines = spawned.rawStdout().split('\n').filter(line => line.trim().length > 0) expect(lines.length).toBeGreaterThan(0) for (const line of lines) { // Every stdout line MUST parse as JSON (a JSON-RPC frame). A non-JSON @@ -177,14 +66,28 @@ describe('acp-agent over real stdio (no key required)', () => { }, 30_000) it('session/new succeeds over real stdio (no model call)', async () => { - // Regression guard (this exact RPC crashed a real Zed session with "cannot get property - // \"agents\" without inject"): `session/new` drives the full bridge → - // `ctx.agents.create({sessionId, meta:{cwd}})` → AgentLoop → registry/persistence path, ALL - // of which run from the JSON-RPC read loop outside the bridge plugin's injection scope. + // REGRESSION GUARD (this exact RPC crashed a real Zed session with + // "cannot get property \"agents\" without inject"): `session/new` drives the + // full bridge → `ctx.agents.create({sessionId, meta:{cwd}})` → AgentLoop → + // registry/persistence path, ALL of which run from the JSON-RPC read loop + // OUTSIDE the bridge plugin's injection scope. A lazy `ctx.` read + // on that path throws and the RPC fails with an Internal error — yet the + // call never touches the model, so this reproduces WITHOUT a key. The + // key-gated prompt test below never caught it (it needs real creds); the + // initialize-only purity test never caught it (initialize does not reach + // the factory). This closes that gap: boot the real subprocess and create a + // session, asserting the RPC RESOLVES (not rejects with an inject error). workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-')) // A dummy key lets the deepseek adapter boot (it only checks presence, not // validity, at apply time); no model call is made, so the key is never used. - spawned = spawnAcpAgent(workdir, { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' }) + spawned = launchAcpTestAgent({ + agent: AGENT, + cwd: workdir, + env: { + DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot', + ...DANGER_FULL_ACCESS_ENV, + }, + }) const { client } = spawned await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) @@ -197,7 +100,7 @@ describe('acp-agent over real stdio (no key required)', () => { describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: real prompt over ACP', () => { it('runs a real turn and the agent writes the requested file (verified on disk)', async () => { workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-')) - spawned = spawnAcpAgent(workdir) + spawned = launchAcpTestAgent({ agent: AGENT, cwd: workdir, env: DANGER_FULL_ACCESS_ENV }) const { client, updates } = spawned await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) @@ -211,29 +114,34 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: real prompt over }) expect(['end_turn', 'max_tokens']).toContain(res.stopReason) - // Verify the filesystem effect rather than the agent's report. + // Verify the WORLD, not the agent's self-report: read the file from disk. const proof = await readFile(join(workdir, 'proof.txt'), 'utf8') expect(proof).toContain('ACP_OK') + // And the client saw tool-call activity stream through. const toolCalls = updates.filter(u => u.sessionUpdate === 'tool_call') expect(toolCalls.length).toBeGreaterThan(0) - // Bash execute cards hide rawInput, so `presentCall` uses the exact command - // as the title rather than the bare tool name "bash". + // Tool-call UI quality (the tool owns its presentation): the bash tool's + // `presentCall` sets the title to the exact command (an execute card hides + // rawInput, so the command IS the title) — NOT the bare tool name "bash". + // A `bash` call must therefore carry an execute kind, a non-"bash" title, + // and a string rawInput (the command). `toolCalls` is already narrowed to + // the `tool_call` shape by the filter above, so these fields are reachable. const bashCall = toolCalls.find(u => u.kind === 'execute') expect(bashCall).toBeDefined() if (bashCall === undefined) throw new Error('expected an execute tool_call') expect(typeof bashCall.title).toBe('string') expect(bashCall.title.length).toBeGreaterThan(0) - expect(bashCall.title).not.toBe('bash') - expect(typeof bashCall.rawInput).toBe('string') - // Without the terminal capability, output uses the console-text path. + expect(bashCall.title).not.toBe('bash') // the old, unhelpful title + expect(typeof bashCall.rawInput).toBe('string') // the exact command + // Capability OFF: no terminal _meta — the ```console text path renders. expect((bashCall as { _meta?: unknown })._meta).toBeUndefined() }, 180_000) it('with the terminal_output capability, a real bash call renders as a terminal card (content + _meta + exit)', async () => { workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-')) - spawned = spawnAcpAgent(workdir) + spawned = launchAcpTestAgent({ agent: AGENT, cwd: workdir, env: DANGER_FULL_ACCESS_ENV }) const { client, updates } = spawned // Advertise the Zed `_meta.terminal_output` capability so the bridge emits diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index e190163a18..124117afef 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -103,14 +103,14 @@ const SCENARIOS: Scenario[] = [ configPath: WORKSPACE_CONTEXT_CONFIG, }, { name: 'cancel', hasModelTurn: true, recorded: false, overridden: true }, - { name: 'subagent-spawn', hasModelTurn: true, recorded: true, childSessions: 1 }, - { name: 'subagent-multi', hasModelTurn: true, recorded: true, childSessions: 2 }, - { name: 'subagent-fork', hasModelTurn: true, recorded: true, childSessions: 1 }, - { name: 'subagent-mixed', hasModelTurn: true, recorded: true, childSessions: 2 }, + { name: 'subagent-spawn', hasModelTurn: true, recorded: true }, + { name: 'subagent-multi', hasModelTurn: true, recorded: true }, + { name: 'subagent-fork', hasModelTurn: true, recorded: true }, + { name: 'subagent-mixed', hasModelTurn: true, recorded: true }, // The workflow tool: the model writes a one-child orchestration script; the // child runs as a spawn subagent under the worker-thread engine (its session is the // child fixture), and the tool result carries the script's return value. - { name: 'workflow-run', hasModelTurn: true, recorded: true, childSessions: 1 }, + { name: 'workflow-run', hasModelTurn: true, recorded: true }, // Authored counterpart to the packaged Python SDK snapshot: mount a live marker, inspect it // through Code Mode, run direct and workflow children, then unmount it. The extra Code Mode and // Cordis plugins require their own request-header pin; the fixture tests deterministic composition. @@ -118,7 +118,6 @@ const SCENARIOS: Scenario[] = [ name: 'advanced-toolchain', hasModelTurn: true, recorded: false, - childSessions: 2, pinsHeader: true, headerClass: 'advanced', configPath: ADVANCED_CONFIG, @@ -135,9 +134,6 @@ const SCENARIOS: Scenario[] = [ { name: 'hook-cc-promptsubmit-context', hasModelTurn: true, recorded: true }, { name: 'hook-cc-pretool-deny', hasModelTurn: true, recorded: true }, { name: 'hook-cc-pretool-ask', hasModelTurn: true, recorded: true }, - // TODO(hook-snapshot-noise): re-record the PostToolUse block fixtures with a - // self-limiting prompt or hook so one rejected result proves the seam without - // repeated block/retry cycles in the committed JSONL. { name: 'hook-cc-posttool-block', hasModelTurn: true, recorded: true }, { name: 'hook-cc-posttool-context', hasModelTurn: true, recorded: true }, { name: 'hook-cc-stop-continue', hasModelTurn: true, recorded: true }, diff --git a/examples/acp-agent/tests/cleanup.e2e.ts b/examples/acp-agent/tests/cleanup.e2e.ts new file mode 100644 index 0000000000..1f6e6493e0 --- /dev/null +++ b/examples/acp-agent/tests/cleanup.e2e.ts @@ -0,0 +1,38 @@ +/** Regression coverage for ACP example teardown. */ + +import { access, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanupAcpExampleTest } from './cleanup.ts' + +let fallbackWorkdir: string | undefined + +afterEach(async () => { + if (fallbackWorkdir !== undefined) await rm(fallbackWorkdir, { recursive: true, force: true }) + fallbackWorkdir = undefined +}) + +describe('cleanupAcpExampleTest', () => { + it('removes the workspace after process shutdown fails', async () => { + fallbackWorkdir = await mkdtemp(join(tmpdir(), 'acp-cleanup-')) + const closeFailure = new Error('close failed') + const spawned = { close: vi.fn().mockRejectedValue(closeFailure) } + + await expect(cleanupAcpExampleTest(spawned, fallbackWorkdir)) + .rejects.toMatchObject({ errors: [closeFailure] }) + await expect(access(fallbackWorkdir)).rejects.toThrow() + fallbackWorkdir = undefined + }) + + it('reports process and workspace failures together', async () => { + const closeFailure = new Error('close failed') + const spawned = { close: vi.fn().mockRejectedValue(closeFailure) } + + const failure = await cleanupAcpExampleTest(spawned, '\0').catch((error: unknown) => error) + + expect(failure).toBeInstanceOf(AggregateError) + expect((failure as AggregateError).errors).toHaveLength(2) + expect((failure as AggregateError).errors[0]).toBe(closeFailure) + }) +}) diff --git a/examples/acp-agent/tests/cleanup.ts b/examples/acp-agent/tests/cleanup.ts new file mode 100644 index 0000000000..28a896334a --- /dev/null +++ b/examples/acp-agent/tests/cleanup.ts @@ -0,0 +1,23 @@ +/** Shared teardown for ACP example tests. */ + +import { rm } from 'node:fs/promises' +import type { LaunchedAcpTestAgent } from '@deepseek-ai/dsh-acp-snapshot' + +/** + * Close the test agent, then remove its workspace, attempting both operations + * and reporting every failure instead of allowing the later one to mask the + * earlier one. + */ +export async function cleanupAcpExampleTest( + spawned: Pick | undefined, + workdir: string | undefined, +): Promise { + const results: PromiseSettledResult[] = [] + if (spawned !== undefined) results.push(...await Promise.allSettled([spawned.close('SIGKILL')])) + if (workdir !== undefined) results.push(...await Promise.allSettled([rm(workdir, { recursive: true, force: true })])) + + const failures = results + .filter((result): result is PromiseRejectedResult => result.status === 'rejected') + .map(result => result.reason as unknown) + if (failures.length > 0) throw new AggregateError(failures, 'ACP example cleanup failed') +} diff --git a/examples/acp-agent/tests/escalation.e2e.ts b/examples/acp-agent/tests/escalation.e2e.ts index 7e1024c23f..59d6d75caa 100644 --- a/examples/acp-agent/tests/escalation.e2e.ts +++ b/examples/acp-agent/tests/escalation.e2e.ts @@ -1,40 +1,49 @@ -import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process' -import { Readable, Writable } from 'node:stream' -import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { spawnSync } from 'node:child_process' +import { mkdtemp, readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' import { - ClientSideConnection, - ndJsonStream, PROTOCOL_VERSION, - type Agent as AcpAgent, - type Client, type RequestPermissionRequest, - type RequestPermissionResponse, - type SessionNotification, } from '@agentclientprotocol/sdk' -import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' +import { + launchAcpTestAgent, + type AgentUnderTest, + type LaunchedAcpTestAgent, +} from '@deepseek-ai/dsh-acp-snapshot' +import { cleanupAcpExampleTest } from './cleanup.ts' /** - * Exercises the default ACP composition through the real bin and Loader. The - * keyless leg boots sandbox, approval, permission, and bridge services, then - * initializes and opens a session without a model call or runner probe. With a - * key and usable runner, the prompt asserts a prior denial; the model requests - * a wider retry with justification, and a scripted client grants or rejects it. - * The filesystem must show that only the granted retry ran. Missing credentials - * or runner support self-skip; real denial markers remain on sandbox e2e tiers. + * The default ACP composition (`cordis.yml`) end to end. + * + * Keyless smoke: boot the REAL `cordis.yml` through the `dsh-acp-agent` bin as + * an ACP subprocess and drive initialize + session/new — the real-Loader-path + * guard (postmortem 0001) for THIS tree's export shapes, which now include the + * sandbox executor AND the approval service. No prompt is sent, so neither the + * model nor a sandbox runner is ever exercised. + * + * With-key escalation flow (self-skips without DEEPSEEK_API_KEY or a usable + * platform runner): a scripted ACP client plays the human. The prompt asserts + * a prior denial (the organic denial→marker path lives on the sandbox e2e + * legs and unit tiers), the real model escalates with `sandbox_permissions` + + * `justification`, the bridge prompts THIS client over + * `session/request_permission`, the client answers `allow-once`, and the + * retried write must land ON DISK (world-verified) — under the granted mode, + * a temp-dir session cwd is writable either way. */ -const binScript = fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)) -const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -// The subprocess runs from a temp cwd outside the repo; point tsx at the repo -// tsconfig so the unbuilt `paths` map resolves in src mode (see examples/AGENTS.md). -const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +const AGENT: AgentUnderTest = { + binScript: fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)), + configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), + tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)), +} -// Without a usable bwrap/Seatbelt runner, the strict attempt fails closed with -// SANDBOX_UNAVAILABLE instead of producing the denial this flow requires. +// A usable confining runner, probed the same way the executor suites do: +// bwrap on Linux, Seatbelt's sandbox-exec on macOS. Without one the strict +// attempt would fail closed (SANDBOX_UNAVAILABLE) instead of producing the +// denial this flow starts from. const hasBwrap = spawnSync('bwrap', ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent', '--', 'true'], { timeout: 5_000, stdio: 'ignore', @@ -45,72 +54,49 @@ const hasSeatbelt = process.platform === 'darwin' && spawnSync('sandbox-exec', [ }).status === 0 const hasRunner = hasBwrap || hasSeatbelt -interface Spawned { - child: ChildProcessWithoutNullStreams - client: ClientSideConnection - updates: SessionNotification['update'][] +interface Spawned extends LaunchedAcpTestAgent { permissionRequests: RequestPermissionRequest[] - stderr: string[] } /** Boot the example as an ACP subprocess; the scripted client answers every permission prompt with `answer`. */ -function spawnAcpAgent(cwd: string, answer: 'allow-once' | 'reject-once'): Spawned { - const launch = resolveExampleLaunch({ - srcBin: binScript, - configArgs: ['--config', configPath], - tsconfigPath: repoTsconfig, - // A dummy key lets the deepseek adapter boot keyless (presence-checked at - // apply, used only on a real model call); the with-key tests carry the - // real key, so the fallback is inert there. - env: { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY || 'sk-dummy-for-boot' }, - }) - const child = spawn( - launch.command, - launch.args, - { cwd, env: { ...process.env, ...launch.env }, stdio: ['pipe', 'pipe', 'pipe'] }, - ) - const stderr: string[] = [] - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => stderr.push(chunk)) - - const updates: SessionNotification['update'][] = [] +function launchExampleAcpAgent(cwd: string, answer: 'allow-once' | 'reject-once'): Spawned { const permissionRequests: RequestPermissionRequest[] = [] - const stream = ndJsonStream( - Writable.toWeb(child.stdin) as WritableStream, - Readable.toWeb(child.stdout) as ReadableStream, - ) - const makeClient = (_agent: AcpAgent): Client => ({ - sessionUpdate(params: SessionNotification): Promise { - updates.push(params.update) - return Promise.resolve() - }, - requestPermission(params: RequestPermissionRequest): Promise { + const launched = launchAcpTestAgent({ + agent: AGENT, + cwd, + // A dummy key lets the adapter boot keylessly; live tests carry the real key. + env: { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' }, + requestPermission(params) { permissionRequests.push(params) const option = params.options.find(o => o.optionId === answer) - // An unexpected prompt shape cancels without granting. + // The scripted human: pick the requested option when the prompt offers + // it; an unexpected prompt shape cancels (fail closed, never grants). if (option === undefined) return Promise.resolve({ outcome: { outcome: 'cancelled' } }) return Promise.resolve({ outcome: { outcome: 'selected', optionId: option.optionId } }) }, }) - const client = new ClientSideConnection(makeClient, stream) - return { child, client, updates, permissionRequests, stderr } + return Object.assign(launched, { permissionRequests }) } let spawned: Spawned | undefined let workdir: string | undefined afterEach(async () => { - if (spawned !== undefined && spawned.child.exitCode === null) spawned.child.kill('SIGKILL') + const ownedSpawned = spawned + const ownedWorkdir = workdir spawned = undefined - if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) workdir = undefined + await cleanupAcpExampleTest(ownedSpawned, ownedWorkdir) }) describe('default sandbox composition keyless smoke (real cordis.yml via the Loader)', () => { it('boots the tree — sandbox executor + approval service + bridge — and opens a session', async () => { workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-smoke-')) - spawned = spawnAcpAgent(workdir, 'reject-once') + spawned = launchExampleAcpAgent(workdir, 'reject-once') const { client } = spawned + // A dummy key boots the adapter; no prompt is ever sent, so no model call + // and no sandbox runner probe happen. This drives the fiber tree the same + // way an editor would, which is what catches a broken export/inject shape. const init = await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) expect(init.protocolVersion).toBe(PROTOCOL_VERSION) const { sessionId } = await client.newSession({ cwd: workdir, mcpServers: [] }) @@ -119,14 +105,18 @@ describe('default sandbox composition keyless smoke (real cordis.yml via the Loa it('advertises model and Permissions selects and honors a permission switch without a model call', async () => { workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-config-')) - spawned = spawnAcpAgent(workdir, 'reject-once') + spawned = launchExampleAcpAgent(workdir, 'reject-once') const { client } = spawned await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + // This tree composes the permission presets over bash-sandbox + approval → + // ONE select advertises, current from the configured default preset. const created = await client.newSession({ cwd: workdir, mcpServers: [] }) const advertised = created.configOptions ?? [] const modelValue = JSON.stringify(['deepseek', 'deepseek-v4-flash']) expect(advertised.map(option => [option.id, 'currentValue' in option ? option.currentValue : undefined])) .toEqual([['model', modelValue], ['permission', 'workspace-write']]) + // A switch responds with the COMPLETE refreshed state (the spec contract), + // and the new current survives in the response of a second switch. const afterFullAccess = await client.setSessionConfigOption({ sessionId: created.sessionId, configId: 'permission', value: 'danger-full-access', }) @@ -137,6 +127,7 @@ describe('default sandbox composition keyless smoke (real cordis.yml via the Loa }) expect((again.configOptions ?? []).map(option => [option.id, 'currentValue' in option ? option.currentValue : undefined])) .toEqual([['model', modelValue], ['permission', 'danger-full-access']]) + // An out-of-vocabulary value is a protocol error, never a silent default. await expect(client.setSessionConfigOption({ sessionId: created.sessionId, configId: 'permission', value: 'plan', })).rejects.toThrow(/unknown permission value/) @@ -146,7 +137,7 @@ describe('default sandbox composition keyless smoke (real cordis.yml via the Loa describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('default sandbox composition e2e: the live approval loop', () => { it('denial → model escalation → editor prompt → allow-once → the retried write lands on disk', async () => { workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-e2e-')) - spawned = spawnAcpAgent(workdir, 'allow-once') + spawned = launchExampleAcpAgent(workdir, 'allow-once') const { client, permissionRequests } = spawned await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) @@ -158,11 +149,13 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('default sandbox co }) expect(['end_turn', 'max_tokens']).toContain(res.stopReason) - // Verify the filesystem, not the model's report. + // The WORLD: the approved escalated retry landed the write. const proof = await readFile(join(workdir, 'escalated.txt'), 'utf8') expect(proof).toContain('ACP_ESCALATION_OK') - // Verify that ACP carried the grant with only one-shot choices. + // The CHANNEL: the grant came through a real session/request_permission + // prompt attached to the escalating tool call, offering exactly the + // one-shot options. expect(permissionRequests.length).toBeGreaterThan(0) const prompt = permissionRequests[0] if (prompt === undefined) throw new Error('expected a permission request') @@ -173,7 +166,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('default sandbox co it('a rejected escalation stays denied: no write lands, the turn still ends', async () => { workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-e2e-')) - spawned = spawnAcpAgent(workdir, 'reject-once') + spawned = launchExampleAcpAgent(workdir, 'reject-once') const { client, permissionRequests } = spawned await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) @@ -185,8 +178,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('default sandbox co }) expect(['end_turn', 'max_tokens']).toContain(res.stopReason) + // The WORLD: rejected means the file never appeared. await expect(readFile(join(workdir, 'refused.txt'), 'utf8')).rejects.toThrow() - // Distinguish a user rejection from a missing approval channel. + // And the rejection really flowed through a prompt (not a missing channel). expect(permissionRequests.length).toBeGreaterThan(0) }, 240_000) }) diff --git a/examples/acp-agent/tests/hooks.e2e.ts b/examples/acp-agent/tests/hooks.e2e.ts index 68dc2648bf..528823f3c5 100644 --- a/examples/acp-agent/tests/hooks.e2e.ts +++ b/examples/acp-agent/tests/hooks.e2e.ts @@ -1,21 +1,15 @@ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' -import { Readable, Writable } from 'node:stream' -import { mkdtemp, rm, writeFile, access } from 'node:fs/promises' +import { mkdtemp, writeFile, access } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { - ClientSideConnection, - ndJsonStream, - PROTOCOL_VERSION, - type Agent as AcpAgent, - type Client, - type RequestPermissionRequest, - type RequestPermissionResponse, - type SessionNotification, -} from '@agentclientprotocol/sdk' -import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' + launchAcpTestAgent, + type AgentUnderTest, + type LaunchedAcpTestAgent, +} from '@deepseek-ai/dsh-acp-snapshot' +import { cleanupAcpExampleTest } from './cleanup.ts' /** * With-key e2e for the Claude hook bridge. The process-level `./hooks.json` is @@ -24,61 +18,21 @@ import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' * The test owns and disposes the ACP subprocess. */ -const binScript = fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)) -const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) - -interface Spawned { - child: ChildProcessWithoutNullStreams - client: ClientSideConnection - updates: SessionNotification['update'][] - stderr: string[] +const AGENT: AgentUnderTest = { + binScript: fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)), + configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), + tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)), } -function spawnAcpAgent(cwd: string): Spawned { - const launch = resolveExampleLaunch({ - srcBin: binScript, - configArgs: ['--config', configPath], - tsconfigPath: repoTsconfig, - env: { DSH_PERMISSION_MODE: 'danger-full-access' }, - }) - const child = spawn( - launch.command, - launch.args, - { cwd, env: { ...process.env, ...launch.env }, stdio: ['pipe', 'pipe', 'pipe'] }, - ) - const stderr: string[] = [] - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => stderr.push(chunk)) - - const updates: SessionNotification['update'][] = [] - const stream = ndJsonStream( - Writable.toWeb(child.stdin) as WritableStream, - Readable.toWeb(child.stdout) as ReadableStream, - ) - const makeClient = (_agent: AcpAgent): Client => ({ - sessionUpdate(params: SessionNotification): Promise { - updates.push(params.update) - return Promise.resolve() - }, - requestPermission(_params: RequestPermissionRequest): Promise { - return Promise.resolve({ outcome: { outcome: 'cancelled' } }) - }, - }) - const client = new ClientSideConnection(makeClient, stream) - return { child, client, updates, stderr } -} - -let spawned: Spawned | undefined +let spawned: LaunchedAcpTestAgent | undefined let workdir: string | undefined afterEach(async () => { - if (spawned) { - spawned.child.kill('SIGKILL') - spawned = undefined - } - if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + const ownedSpawned = spawned + const ownedWorkdir = workdir + spawned = undefined workdir = undefined + await cleanupAcpExampleTest(ownedSpawned, ownedWorkdir) }) describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: a PreToolUse hook blocks bash (real model)', () => { @@ -90,7 +44,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: a PreToolUse hook hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'echo "bash blocked by policy" >&2; exit 2' }] }] }, })) - spawned = spawnAcpAgent(workdir) + spawned = launchAcpTestAgent({ + agent: AGENT, + cwd: workdir, + env: { DSH_PERMISSION_MODE: 'danger-full-access' }, + }) const { client, updates } = spawned await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl index 45d5628762..ce3fe8acef 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-mount","title":"Mount plugin into live cordis runtime","kind":"execute","status":"in_progress","rawInput":{"code":"return { name: 'snapshot-marker', apply() {} }"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"advanced-mount","status":"completed","content":[{"type":"content","content":{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-code","title":"return await tools.cordis_inspect({ what: 'dynamic' })","kind":"execute","status":"in_progress","rawInput":"return await tools.cordis_inspect({ what: 'dynamic' })"}}} diff --git a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl index ddd1f06a55..9782b0fd7d 100644 --- a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl @@ -10,7 +10,7 @@ {"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} {"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}} -{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snapshot-spill/session-ad78080217cd/dccd97e3c558-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snapshot-spill/session-8d519f752b89/93e1b6e8dc7e-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} {"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/acp-agent/tests/snapshots/bash-spill/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/stdout.golden.jsonl index ad6c396981..d9d2632bd0 100644 --- a/examples/acp-agent/tests/snapshots/bash-spill/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-spill/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_spill","title":"node -e \"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\"","kind":"execute","status":"in_progress","rawInput":"node -e \"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\"","content":[{"type":"content","content":{"type":"text","text":"Print large deterministic output"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_spill","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\nSPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: {{spillLocator:bash.txt}}. Use read with offset/limit, or grep this path to search within it.)\n```"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl index 7b2dbd604c..4111ecc8de 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/cancel/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/cancel/stdout.golden.jsonl index d31c8fdcfb..4146e8804d 100644 --- a/examples/acp-agent/tests/snapshots/cancel/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel/stdout.golden.jsonl @@ -1,4 +1,4 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"partial"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl index 31ec5df39a..f3bd0b345c 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.golden.jsonl index fcf219e5ea..3d25d176ac 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl index 1aca0c6586..47dc73536f 100644 --- a/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} -{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} -{"jsonrpc":"2.0","id":4,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":4,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":5,"error":{"code":-32602,"message":"Invalid params: unknown permission value \"plan\""}} {"jsonrpc":"2.0","id":6,"error":{"code":-32602,"message":"Invalid params: unknown config option \"reasoning-effort\""}} diff --git a/examples/acp-agent/tests/snapshots/error-finish/replay.override.json b/examples/acp-agent/tests/snapshots/error-finish/replay.override.json index eea32f25ca..cfa0d84227 100644 --- a/examples/acp-agent/tests/snapshots/error-finish/replay.override.json +++ b/examples/acp-agent/tests/snapshots/error-finish/replay.override.json @@ -1,3 +1,3 @@ [ - { "kind": "throw", "chunks": [], "message": "simulated provider error (HTTP 401)", "code": "AUTH", "status": 401 } + { "kind": "throw", "chunks": [], "message": "simulated provider error (HTTP 401)", "code": "AUTH" } ] diff --git a/examples/acp-agent/tests/snapshots/error-finish/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/error-finish/stdout.golden.jsonl index 484b241427..540eb2338a 100644 --- a/examples/acp-agent/tests/snapshots/error-finish/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/error-finish/stdout.golden.jsonl @@ -1,3 +1,3 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"error":{"code":-32603,"message":"Internal error: turn failed: simulated provider error (HTTP 401)"}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index b420fac975..2a33a4e15e 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -131,8 +131,8 @@ {"type":"assistant/chunk","seq":129,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":130,"time":1783962245385,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129],"surfaceOp":"append"} {"type":"tool/call","seq":131,"time":1783962245385,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"19974166-a6ad-4f46-bef8-ce7d6bda3214","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"19974166-a6ad-4f46-bef8-ce7d6bda3214","outcome":"allowed-once"}} +{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"4ba41d82-153a-4587-8c22-dd35783c3d87","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"4ba41d82-153a-4587-8c22-dd35783c3d87","outcome":"allowed-once"}} {"type":"tool/result","seq":134,"time":1783962245399,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[131],"surfaceOp":"append"} {"type":"step/end","seq":135,"time":1783962245400,"data":{"turn":1,"step":1}} {"type":"step/start","seq":136,"time":1783962245400,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl index 9180f2426a..92743b8133 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} -{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index 4b45911a66..0e19158f2c 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -155,8 +155,8 @@ {"type":"assistant/chunk","seq":153,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":154,"time":1783962246274,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153],"surfaceOp":"append"} {"type":"tool/call","seq":155,"time":1783962246274,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"564d6e1b-4330-42bd-a646-461e7a6c2d1a","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"564d6e1b-4330-42bd-a646-461e7a6c2d1a","outcome":"rejected"}} +{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"84c78b9b-8955-4d4f-8dad-824dcac4d9ed","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"84c78b9b-8955-4d4f-8dad-824dcac4d9ed","outcome":"rejected"}} {"type":"tool/result","seq":158,"time":1783962246275,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[155],"surfaceOp":"append"} {"type":"step/end","seq":159,"time":1783962246276,"data":{"turn":1,"step":1}} {"type":"step/start","seq":160,"time":1783962246276,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl index 871f3e3281..c8a9b320f0 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} -{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl index df664a96b4..fab47cc857 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl index dda4f70968..d92ed5520b 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl index fde77464d8..44ce1184e9 100644 --- a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl index d6b2e00b1e..712e8e5c3b 100644 --- a/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl index b35af2c650..d06162a005 100644 --- a/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl index c5dcf0a7b0..270d1ace7c 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl index 7969eeba3b..1cac540a29 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/handshake/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/handshake/stdout.golden.jsonl index 011175a871..fb4f7cbbc5 100644 --- a/examples/acp-agent/tests/snapshots/handshake/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/handshake/stdout.golden.jsonl @@ -1,2 +1,2 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/input.json b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/input.json index 3d44990f9b..fac587034a 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/input.json +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/input.json @@ -2,6 +2,6 @@ "steps": [ { "op": "initialize" }, { "op": "newSession" }, - { "op": "prompt", "text": "Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop." } + { "op": "prompt", "text": "Call the bash tool to run exactly: echo HELLO. If the first tool result is rejected, retry that command once. Quote the final tool result verbatim and stop." } ] } diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl index 07bd28c2cd..be1d70a853 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl @@ -1,752 +1,177 @@ -{"type":"session","version":0,"id":"4da131bc-e9b8-4228-9d27-83ac4d109ef6","createdAt":1783352177362,"cwd":"/tmp/acp-snap-cwd-t5Q4CC"} -{"type":"turn/start","seq":0,"time":1783352177366,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352177367,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783352177368,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783352177372,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783352178017,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783352178018,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783352178131,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783352178159,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783352178159,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783352178159,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783352178160,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":11,"time":1783352178160,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":12,"time":1783352178187,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} -{"type":"assistant/chunk","seq":13,"time":1783352178187,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" HE"}}} -{"type":"assistant/chunk","seq":14,"time":1783352178187,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} -{"type":"assistant/chunk","seq":15,"time":1783352178187,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} -{"type":"assistant/chunk","seq":16,"time":1783352178187,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} -{"type":"assistant/chunk","seq":17,"time":1783352178188,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} -{"type":"assistant/chunk","seq":18,"time":1783352178216,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":19,"time":1783352178245,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} -{"type":"assistant/chunk","seq":20,"time":1783352178245,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":21,"time":1783352178245,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":22,"time":1783352178246,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} -{"type":"assistant/chunk","seq":23,"time":1783352178246,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":24,"time":1783352178246,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} -{"type":"assistant/chunk","seq":25,"time":1783352178275,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} -{"type":"assistant/chunk","seq":26,"time":1783352178276,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} -{"type":"assistant/chunk","seq":27,"time":1783352178276,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":28,"time":1783352178359,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":29,"time":1783352178360,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":30,"time":1783352178360,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":31,"time":1783352178360,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":32,"time":1783352178395,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":33,"time":1783352178395,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":34,"time":1783352178395,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":35,"time":1783352178395,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":36,"time":1783352178416,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":37,"time":1783352178417,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":" HE"}}} -{"type":"assistant/chunk","seq":38,"time":1783352178417,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"LL"}}} -{"type":"assistant/chunk","seq":39,"time":1783352178417,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"O"}}} -{"type":"assistant/chunk","seq":40,"time":1783352178417,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":41,"time":1783352178473,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":42,"time":1783352178473,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":43,"time":1783352178473,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":44,"time":1783352178473,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":45,"time":1783352178473,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":46,"time":1783352178500,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":47,"time":1783352178500,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"E"}}} -{"type":"assistant/chunk","seq":48,"time":1783352178500,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"cho"}}} -{"type":"assistant/chunk","seq":49,"time":1783352178501,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":" HE"}}} -{"type":"assistant/chunk","seq":50,"time":1783352178501,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"LL"}}} -{"type":"assistant/chunk","seq":51,"time":1783352178501,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"O"}}} -{"type":"assistant/chunk","seq":52,"time":1783352178531,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":" to"}}} -{"type":"assistant/chunk","seq":53,"time":1783352178532,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":" stdout"}}} -{"type":"assistant/chunk","seq":54,"time":1783352178532,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":55,"time":1783352178560,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":56,"time":1783352178591,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."}}}} -{"type":"assistant/chunk","seq":57,"time":1783352178592,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}}}} -{"type":"assistant/chunk","seq":58,"time":1783352178592,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2879,"outputTokens":91,"cacheReadTokens":0,"reasoningTokens":23}}}} -{"type":"assistant/chunk","seq":59,"time":1783352178592,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":60,"time":1783352178594,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."},{"type":"tool-call","id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2879,"outputTokens":91,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59],"surfaceOp":"append"} -{"type":"tool/call","seq":61,"time":1783352178594,"data":{"turn":1,"step":1,"callId":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}} -{"type":"hook/invoked","seq":62,"time":1783352178614,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:1","matcher":"bash"}} -{"type":"hook/result","seq":63,"time":1783352178624,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:1","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: rerun with a summary instead","durationMs":9.49630100000013}} -{"type":"tool/result","seq":64,"time":1783352178625,"data":{"turn":1,"step":1,"callId":"call_00_qcIzLImnOm5qiKOBJUqY5047","content":[{"type":"text","text":"tool output rejected by policy: rerun with a summary instead"}],"isError":true},"sourceEventSeqs":[61],"surfaceOp":"append"} -{"type":"step/end","seq":65,"time":1783352178625,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":66,"time":1783352178626,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":67,"time":1783352179685,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":68,"time":1783352179685,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":69,"time":1783352179799,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":70,"time":1783352179828,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} -{"type":"assistant/chunk","seq":71,"time":1783352179856,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} -{"type":"assistant/chunk","seq":72,"time":1783352179856,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} -{"type":"assistant/chunk","seq":73,"time":1783352179856,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} -{"type":"assistant/chunk","seq":74,"time":1783352179885,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} -{"type":"assistant/chunk","seq":75,"time":1783352179885,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":76,"time":1783352179913,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" which"}}} -{"type":"assistant/chunk","seq":77,"time":1783352179914,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" says"}}} -{"type":"assistant/chunk","seq":78,"time":1783352179942,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":79,"time":1783352179970,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"rer"}}} -{"type":"assistant/chunk","seq":80,"time":1783352179971,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"un"}}} -{"type":"assistant/chunk","seq":81,"time":1783352179971,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":82,"time":1783352179971,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":83,"time":1783352179971,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" summary"}}} -{"type":"assistant/chunk","seq":84,"time":1783352179971,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instead"}}} -{"type":"assistant/chunk","seq":85,"time":1783352179999,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":86,"time":1783352179999,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":87,"time":1783352180000,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":88,"time":1783352180000,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" try"}}} -{"type":"assistant/chunk","seq":89,"time":1783352180000,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" again"}}} -{"type":"assistant/chunk","seq":90,"time":1783352180028,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":91,"time":1783352180028,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":92,"time":1783352180029,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" description"}}} -{"type":"assistant/chunk","seq":93,"time":1783352180055,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":94,"time":1783352180055,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" summarizes"}}} -{"type":"assistant/chunk","seq":95,"time":1783352180083,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} -{"type":"assistant/chunk","seq":96,"time":1783352180122,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" we"}}} -{"type":"assistant/chunk","seq":97,"time":1783352180141,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'re"}}} -{"type":"assistant/chunk","seq":98,"time":1783352180141,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" doing"}}} -{"type":"assistant/chunk","seq":99,"time":1783352180141,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":100,"time":1783352180226,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":101,"time":1783352180226,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":102,"time":1783352180227,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":103,"time":1783352180227,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":104,"time":1783352180255,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":105,"time":1783352180255,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":106,"time":1783352180255,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":107,"time":1783352180255,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":108,"time":1783352180283,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":109,"time":1783352180283,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":" HE"}}} -{"type":"assistant/chunk","seq":110,"time":1783352180283,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"LL"}}} -{"type":"assistant/chunk","seq":111,"time":1783352180283,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"O"}}} -{"type":"assistant/chunk","seq":112,"time":1783352180283,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":113,"time":1783352180343,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":114,"time":1783352180343,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":115,"time":1783352180343,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":116,"time":1783352180343,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":117,"time":1783352180343,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":118,"time":1783352180372,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":119,"time":1783352180372,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"Run"}}} -{"type":"assistant/chunk","seq":120,"time":1783352180372,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":" echo"}}} -{"type":"assistant/chunk","seq":121,"time":1783352180372,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":" HE"}}} -{"type":"assistant/chunk","seq":122,"time":1783352180400,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"LL"}}} -{"type":"assistant/chunk","seq":123,"time":1783352180401,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"O"}}} -{"type":"assistant/chunk","seq":124,"time":1783352180401,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":" command"}}} -{"type":"assistant/chunk","seq":125,"time":1783352180428,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":126,"time":1783352180429,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":127,"time":1783352180488,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The tool output was rejected by policy, which says \"rerun with a summary instead\". Let me try again with a description that summarizes what we're doing."}}}} -{"type":"assistant/chunk","seq":128,"time":1783352180488,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO command\"}"}}}} -{"type":"assistant/chunk","seq":129,"time":1783352180489,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":178,"outputTokens":99,"cacheReadTokens":2816,"reasoningTokens":32}}}} -{"type":"assistant/chunk","seq":130,"time":1783352180489,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":131,"time":1783352180489,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The tool output was rejected by policy, which says \"rerun with a summary instead\". Let me try again with a description that summarizes what we're doing."},{"type":"tool-call","id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO command\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":178,"outputTokens":99,"cacheReadTokens":2816,"reasoningTokens":32}},"sourceEventSeqs":[67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"} -{"type":"tool/call","seq":132,"time":1783352180489,"data":{"turn":1,"step":2,"callId":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO command\"}"}} -{"type":"hook/invoked","seq":133,"time":1783352180524,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:2","matcher":"bash"}} -{"type":"hook/result","seq":134,"time":1783352180530,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:2","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: rerun with a summary instead","durationMs":6.622174999999515}} -{"type":"tool/result","seq":135,"time":1783352180531,"data":{"turn":1,"step":2,"callId":"call_00_nzhKxYvCYj8OqI2tjOKb8506","content":[{"type":"text","text":"tool output rejected by policy: rerun with a summary instead"}],"isError":true},"sourceEventSeqs":[132],"surfaceOp":"append"} -{"type":"step/end","seq":136,"time":1783352180531,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":137,"time":1783352180531,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":138,"time":1783352181379,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":139,"time":1783352181379,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":140,"time":1783352181496,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":141,"time":1783352181524,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" keeps"}}} -{"type":"assistant/chunk","seq":142,"time":1783352181524,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" rejecting"}}} -{"type":"assistant/chunk","seq":143,"time":1783352181524,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":144,"time":1783352181553,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":145,"time":1783352181553,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":146,"time":1783352181553,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" try"}}} -{"type":"assistant/chunk","seq":147,"time":1783352181553,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":148,"time":1783352181553,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" slightly"}}} -{"type":"assistant/chunk","seq":149,"time":1783352181582,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" different"}}} -{"type":"assistant/chunk","seq":150,"time":1783352181582,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" description"}}} -{"type":"assistant/chunk","seq":151,"time":1783352181583,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":152,"time":1783352181668,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":153,"time":1783352181668,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":154,"time":1783352181701,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":155,"time":1783352181702,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":156,"time":1783352181702,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":157,"time":1783352181726,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":158,"time":1783352181726,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":159,"time":1783352181726,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":160,"time":1783352181726,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"Print"}}} -{"type":"assistant/chunk","seq":161,"time":1783352181758,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":" HE"}}} -{"type":"assistant/chunk","seq":162,"time":1783352181758,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"LL"}}} -{"type":"assistant/chunk","seq":163,"time":1783352181758,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"O"}}} -{"type":"assistant/chunk","seq":164,"time":1783352181758,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":" to"}}} -{"type":"assistant/chunk","seq":165,"time":1783352181788,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":" stdout"}}} -{"type":"assistant/chunk","seq":166,"time":1783352181788,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":167,"time":1783352181816,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":168,"time":1783352181816,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":169,"time":1783352181816,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":170,"time":1783352181816,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":171,"time":1783352181844,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":172,"time":1783352181844,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":173,"time":1783352181844,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":174,"time":1783352181845,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":" HE"}}} -{"type":"assistant/chunk","seq":175,"time":1783352181874,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"LL"}}} -{"type":"assistant/chunk","seq":176,"time":1783352181874,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"O"}}} -{"type":"assistant/chunk","seq":177,"time":1783352181874,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":178,"time":1783352181905,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":179,"time":1783352181933,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The tool keeps rejecting. Let me try a slightly different description."}}}} -{"type":"assistant/chunk","seq":180,"time":1783352181933,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","arguments":"{\"description\": \"Print HELLO to stdout\", \"command\": \"echo HELLO\"}"}}}} -{"type":"assistant/chunk","seq":181,"time":1783352181933,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":173,"outputTokens":80,"cacheReadTokens":2944,"reasoningTokens":13}}}} -{"type":"assistant/chunk","seq":182,"time":1783352181933,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":183,"time":1783352181934,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The tool keeps rejecting. Let me try a slightly different description."},{"type":"tool-call","id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","arguments":"{\"description\": \"Print HELLO to stdout\", \"command\": \"echo HELLO\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":173,"outputTokens":80,"cacheReadTokens":2944,"reasoningTokens":13}},"sourceEventSeqs":[138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182],"surfaceOp":"append"} -{"type":"tool/call","seq":184,"time":1783352181934,"data":{"turn":1,"step":3,"callId":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","arguments":"{\"description\": \"Print HELLO to stdout\", \"command\": \"echo HELLO\"}"}} -{"type":"hook/invoked","seq":185,"time":1783352181945,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:3","matcher":"bash"}} -{"type":"hook/result","seq":186,"time":1783352181953,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:3","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: rerun with a summary instead","durationMs":7.827785000000404}} -{"type":"tool/result","seq":187,"time":1783352181953,"data":{"turn":1,"step":3,"callId":"call_00_VumDDhhB4n5507EUXq650912","content":[{"type":"text","text":"tool output rejected by policy: rerun with a summary instead"}],"isError":true},"sourceEventSeqs":[184],"surfaceOp":"append"} -{"type":"step/end","seq":188,"time":1783352181953,"data":{"turn":1,"step":3}} -{"type":"step/start","seq":189,"time":1783352181953,"data":{"turn":1,"step":4}} -{"type":"assistant/chunk","seq":190,"time":1783352182452,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":191,"time":1783352182452,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":192,"time":1783352182586,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} -{"type":"assistant/chunk","seq":193,"time":1783352182611,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" keeps"}}} -{"type":"assistant/chunk","seq":194,"time":1783352182640,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" rejecting"}}} -{"type":"assistant/chunk","seq":195,"time":1783352182641,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" my"}}} -{"type":"assistant/chunk","seq":196,"time":1783352182641,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" attempts"}}} -{"type":"assistant/chunk","seq":197,"time":1783352182668,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":198,"time":1783352182669,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":199,"time":1783352182669,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":200,"time":1783352182669,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" try"}}} -{"type":"assistant/chunk","seq":201,"time":1783352182669,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":202,"time":1783352182697,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" very"}}} -{"type":"assistant/chunk","seq":203,"time":1783352182701,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" short"}}} -{"type":"assistant/chunk","seq":204,"time":1783352182701,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" description"}}} -{"type":"assistant/chunk","seq":205,"time":1783352182729,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":206,"time":1783352182787,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":207,"time":1783352182787,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":208,"time":1783352182816,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":209,"time":1783352182816,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":210,"time":1783352182816,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":211,"time":1783352182817,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":212,"time":1783352182844,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":213,"time":1783352182845,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":214,"time":1783352182845,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":215,"time":1783352182845,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":" HE"}}} -{"type":"assistant/chunk","seq":216,"time":1783352182873,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"LL"}}} -{"type":"assistant/chunk","seq":217,"time":1783352182873,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"O"}}} -{"type":"assistant/chunk","seq":218,"time":1783352182873,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":219,"time":1783352182905,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":220,"time":1783352182905,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":221,"time":1783352182905,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":222,"time":1783352182930,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":223,"time":1783352182930,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":224,"time":1783352182930,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":225,"time":1783352182931,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":226,"time":1783352182960,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":" HE"}}} -{"type":"assistant/chunk","seq":227,"time":1783352182961,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"LL"}}} -{"type":"assistant/chunk","seq":228,"time":1783352182961,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"O"}}} -{"type":"assistant/chunk","seq":229,"time":1783352182961,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":230,"time":1783352182987,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":231,"time":1783352183049,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The policy keeps rejecting my attempts. Let me try a very short description."}}}} -{"type":"assistant/chunk","seq":232,"time":1783352183049,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","arguments":"{\"description\": \"echo HELLO\", \"command\": \"echo HELLO\"}"}}}} -{"type":"assistant/chunk","seq":233,"time":1783352183049,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":149,"outputTokens":80,"cacheReadTokens":3072,"reasoningTokens":15}}}} -{"type":"assistant/chunk","seq":234,"time":1783352183049,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":235,"time":1783352183049,"data":{"turn":1,"step":4,"content":[{"type":"reasoning","text":"The policy keeps rejecting my attempts. Let me try a very short description."},{"type":"tool-call","id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","arguments":"{\"description\": \"echo HELLO\", \"command\": \"echo HELLO\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":149,"outputTokens":80,"cacheReadTokens":3072,"reasoningTokens":15}},"sourceEventSeqs":[190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234],"surfaceOp":"append"} -{"type":"tool/call","seq":236,"time":1783352183050,"data":{"turn":1,"step":4,"callId":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","arguments":"{\"description\": \"echo HELLO\", \"command\": \"echo HELLO\"}"}} -{"type":"hook/invoked","seq":237,"time":1783352183069,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:4","matcher":"bash"}} -{"type":"hook/result","seq":238,"time":1783352183077,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:4","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: rerun with a summary instead","durationMs":8.41934399999991}} -{"type":"tool/result","seq":239,"time":1783352183077,"data":{"turn":1,"step":4,"callId":"call_00_i5NCqPa2LGAy1wk8uJQR2369","content":[{"type":"text","text":"tool output rejected by policy: rerun with a summary instead"}],"isError":true},"sourceEventSeqs":[236],"surfaceOp":"append"} -{"type":"step/end","seq":240,"time":1783352183078,"data":{"turn":1,"step":4}} -{"type":"step/start","seq":241,"time":1783352183078,"data":{"turn":1,"step":5}} -{"type":"assistant/chunk","seq":242,"time":1783352183709,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":243,"time":1783352183709,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":"H"}}} -{"type":"assistant/chunk","seq":244,"time":1783352183821,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":"mm"}}} -{"type":"assistant/chunk","seq":245,"time":1783352183847,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":246,"time":1783352183848,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":247,"time":1783352183848,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} -{"type":"assistant/chunk","seq":248,"time":1783352183848,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" keeps"}}} -{"type":"assistant/chunk","seq":249,"time":1783352183876,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" rejecting"}}} -{"type":"assistant/chunk","seq":250,"time":1783352183877,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":251,"time":1783352183877,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":252,"time":1783352183877,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":253,"time":1783352183877,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" try"}}} -{"type":"assistant/chunk","seq":254,"time":1783352183877,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" without"}}} -{"type":"assistant/chunk","seq":255,"time":1783352183907,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":256,"time":1783352183908,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" description"}}} -{"type":"assistant/chunk","seq":257,"time":1783352183908,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" field"}}} -{"type":"assistant/chunk","seq":258,"time":1783352183936,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":259,"time":1783352183936,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} -{"type":"assistant/chunk","seq":260,"time":1783352183967,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":261,"time":1783352183992,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":262,"time":1783352183993,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":263,"time":1783352184053,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":264,"time":1783352184053,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0IuOIk6iuG6ZesROSyAM3669","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":265,"time":1783352184082,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0IuOIk6iuG6ZesROSyAM3669","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":266,"time":1783352184082,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0IuOIk6iuG6ZesROSyAM3669","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":267,"time":1783352184082,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0IuOIk6iuG6ZesROSyAM3669","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":268,"time":1783352184111,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0IuOIk6iuG6ZesROSyAM3669","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":269,"time":1783352184111,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0IuOIk6iuG6ZesROSyAM3669","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":270,"time":1783352184111,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0IuOIk6iuG6ZesROSyAM3669","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":271,"time":1783352184111,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0IuOIk6iuG6ZesROSyAM3669","name":"bash","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":272,"time":1783352184140,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0IuOIk6iuG6ZesROSyAM3669","name":"bash","argumentsDelta":" HE"}}} -{"type":"assistant/chunk","seq":273,"time":1783352184140,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0IuOIk6iuG6ZesROSyAM3669","name":"bash","argumentsDelta":"LL"}}} -{"type":"assistant/chunk","seq":274,"time":1783352184140,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0IuOIk6iuG6ZesROSyAM3669","name":"bash","argumentsDelta":"O"}}} -{"type":"assistant/chunk","seq":275,"time":1783352184140,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0IuOIk6iuG6ZesROSyAM3669","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":276,"time":1783352184168,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0IuOIk6iuG6ZesROSyAM3669","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":277,"time":1783352184233,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Hmm, the policy keeps rejecting. Let me try without the description field, just the command."}}}} -{"type":"assistant/chunk","seq":278,"time":1783352184233,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_0IuOIk6iuG6ZesROSyAM3669","name":"bash","arguments":"{\"command\": \"echo HELLO\"}"}}}} -{"type":"assistant/chunk","seq":279,"time":1783352184233,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":125,"outputTokens":66,"cacheReadTokens":3200,"reasoningTokens":20}}}} -{"type":"assistant/chunk","seq":280,"time":1783352184233,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":281,"time":1783352184233,"data":{"turn":1,"step":5,"content":[{"type":"reasoning","text":"Hmm, the policy keeps rejecting. Let me try without the description field, just the command."},{"type":"tool-call","id":"call_00_0IuOIk6iuG6ZesROSyAM3669","name":"bash","arguments":"{\"command\": \"echo HELLO\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":125,"outputTokens":66,"cacheReadTokens":3200,"reasoningTokens":20}},"sourceEventSeqs":[242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280],"surfaceOp":"append"} -{"type":"tool/call","seq":282,"time":1783352184233,"data":{"turn":1,"step":5,"callId":"call_00_0IuOIk6iuG6ZesROSyAM3669","name":"bash","arguments":"{\"command\": \"echo HELLO\"}"}} -{"type":"hook/invoked","seq":283,"time":1783352184234,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:5","matcher":"bash"}} -{"type":"hook/result","seq":284,"time":1783352184243,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:5","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: rerun with a summary instead","durationMs":8.810661999999866}} -{"type":"tool/result","seq":285,"time":1783352184243,"data":{"turn":1,"step":5,"callId":"call_00_0IuOIk6iuG6ZesROSyAM3669","content":[{"type":"text","text":"tool output rejected by policy: rerun with a summary instead"}],"isError":true},"sourceEventSeqs":[282],"surfaceOp":"append"} -{"type":"step/end","seq":286,"time":1783352184243,"data":{"turn":1,"step":5}} -{"type":"step/start","seq":287,"time":1783352184244,"data":{"turn":1,"step":6}} -{"type":"assistant/chunk","seq":288,"time":1783352185025,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":289,"time":1783352185025,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":290,"time":1783352185125,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":291,"time":1783352185156,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":292,"time":1783352185157,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" consistently"}}} -{"type":"assistant/chunk","seq":293,"time":1783352185157,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" being"}}} -{"type":"assistant/chunk","seq":294,"time":1783352185188,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} -{"type":"assistant/chunk","seq":295,"time":1783352185188,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} -{"type":"assistant/chunk","seq":296,"time":1783352185212,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} -{"type":"assistant/chunk","seq":297,"time":1783352185241,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":298,"time":1783352185242,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} -{"type":"assistant/chunk","seq":299,"time":1783352185242,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} -{"type":"assistant/chunk","seq":300,"time":1783352185270,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" says"}}} -{"type":"assistant/chunk","seq":301,"time":1783352185270,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":302,"time":1783352185271,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"rer"}}} -{"type":"assistant/chunk","seq":303,"time":1783352185271,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"un"}}} -{"type":"assistant/chunk","seq":304,"time":1783352185271,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":305,"time":1783352185299,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":306,"time":1783352185299,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" summary"}}} -{"type":"assistant/chunk","seq":307,"time":1783352185300,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" instead"}}} -{"type":"assistant/chunk","seq":308,"time":1783352185300,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":309,"time":1783352185300,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" -"}}} -{"type":"assistant/chunk","seq":310,"time":1783352185300,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":311,"time":1783352185328,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" seems"}}} -{"type":"assistant/chunk","seq":312,"time":1783352185329,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":313,"time":1783352185329,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" content"}}} -{"type":"assistant/chunk","seq":314,"time":1783352185358,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}} -{"type":"assistant/chunk","seq":315,"time":1783352185390,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":316,"time":1783352185391,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} -{"type":"assistant/chunk","seq":317,"time":1783352185391,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} -{"type":"assistant/chunk","seq":318,"time":1783352185418,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"which"}}} -{"type":"assistant/chunk","seq":319,"time":1783352185419,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" would"}}} -{"type":"assistant/chunk","seq":320,"time":1783352185448,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" be"}}} -{"type":"assistant/chunk","seq":321,"time":1783352185448,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":322,"time":1783352185448,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"HE"}}} -{"type":"assistant/chunk","seq":323,"time":1783352185448,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} -{"type":"assistant/chunk","seq":324,"time":1783352185448,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} -{"type":"assistant/chunk","seq":325,"time":1783352185449,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"\")"}}} -{"type":"assistant/chunk","seq":326,"time":1783352185476,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":327,"time":1783352185477,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" being"}}} -{"type":"assistant/chunk","seq":328,"time":1783352185477,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" blocked"}}} -{"type":"assistant/chunk","seq":329,"time":1783352185506,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":330,"time":1783352185535,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":331,"time":1783352185535,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":332,"time":1783352185535,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" try"}}} -{"type":"assistant/chunk","seq":333,"time":1783352185536,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":334,"time":1783352185563,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" different"}}} -{"type":"assistant/chunk","seq":335,"time":1783352185564,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" approach"}}} -{"type":"assistant/chunk","seq":336,"time":1783352185564,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" -"}}} -{"type":"assistant/chunk","seq":337,"time":1783352185593,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" maybe"}}} -{"type":"assistant/chunk","seq":338,"time":1783352185594,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":339,"time":1783352185594,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} -{"type":"assistant/chunk","seq":340,"time":1783352185623,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":341,"time":1783352185623,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} -{"type":"assistant/chunk","seq":342,"time":1783352185623,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":343,"time":1783352185652,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" different"}}} -{"type":"assistant/chunk","seq":344,"time":1783352185652,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":345,"time":1783352185680,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" or"}}} -{"type":"assistant/chunk","seq":346,"time":1783352185680,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" approach"}}} -{"type":"assistant/chunk","seq":347,"time":1783352185713,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":348,"time":1783352185738,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" satisfy"}}} -{"type":"assistant/chunk","seq":349,"time":1783352185767,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":350,"time":1783352185768,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} -{"type":"assistant/chunk","seq":351,"time":1783352185797,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":".\n\n"}}} -{"type":"assistant/chunk","seq":352,"time":1783352185801,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"Wait"}}} -{"type":"assistant/chunk","seq":353,"time":1783352185826,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":354,"time":1783352185827,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":355,"time":1783352185827,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":356,"time":1783352185827,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" explicitly"}}} -{"type":"assistant/chunk","seq":357,"time":1783352185854,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} -{"type":"assistant/chunk","seq":358,"time":1783352185855,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":359,"time":1783352185855,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":360,"time":1783352185855,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":361,"time":1783352185884,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"run"}}} -{"type":"assistant/chunk","seq":362,"time":1783352185884,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":363,"time":1783352185884,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} -{"type":"assistant/chunk","seq":364,"time":1783352185914,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" echo"}}} -{"type":"assistant/chunk","seq":365,"time":1783352185915,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" HE"}}} -{"type":"assistant/chunk","seq":366,"time":1783352185915,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} -{"type":"assistant/chunk","seq":367,"time":1783352185915,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} -{"type":"assistant/chunk","seq":368,"time":1783352185915,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":369,"time":1783352185915,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":370,"time":1783352185942,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":371,"time":1783352185973,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"Report"}}} -{"type":"assistant/chunk","seq":372,"time":1783352186002,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":373,"time":1783352186002,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":374,"time":1783352186002,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} -{"type":"assistant/chunk","seq":375,"time":1783352186002,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" you"}}} -{"type":"assistant/chunk","seq":376,"time":1783352186031,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" got"}}} -{"type":"assistant/chunk","seq":377,"time":1783352186032,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" back"}}} -{"type":"assistant/chunk","seq":378,"time":1783352186032,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} -{"type":"assistant/chunk","seq":379,"time":1783352186032,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} -{"type":"assistant/chunk","seq":380,"time":1783352186032,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":381,"time":1783352186032,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":382,"time":1783352186060,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":383,"time":1783352186060,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" try"}}} -{"type":"assistant/chunk","seq":384,"time":1783352186061,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":385,"time":1783352186061,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} -{"type":"assistant/chunk","seq":386,"time":1783352186089,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":387,"time":1783352186139,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"HE"}}} -{"type":"assistant/chunk","seq":388,"time":1783352186146,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} -{"type":"assistant/chunk","seq":389,"time":1783352186147,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} -{"type":"assistant/chunk","seq":390,"time":1783352186147,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":391,"time":1783352186147,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} -{"type":"assistant/chunk","seq":392,"time":1783352186147,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":393,"time":1783352186175,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" quotes"}}} -{"type":"assistant/chunk","seq":394,"time":1783352186175,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":395,"time":1783352186233,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":396,"time":1783352186233,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":397,"time":1783352186262,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":398,"time":1783352186262,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":399,"time":1783352186262,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":400,"time":1783352186290,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":401,"time":1783352186291,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":402,"time":1783352186291,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":403,"time":1783352186291,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"E"}}} -{"type":"assistant/chunk","seq":404,"time":1783352186320,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"cho"}}} -{"type":"assistant/chunk","seq":405,"time":1783352186320,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":" the"}}} -{"type":"assistant/chunk","seq":406,"time":1783352186353,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":" string"}}} -{"type":"assistant/chunk","seq":407,"time":1783352186353,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":" HE"}}} -{"type":"assistant/chunk","seq":408,"time":1783352186353,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"LL"}}} -{"type":"assistant/chunk","seq":409,"time":1783352186353,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"O"}}} -{"type":"assistant/chunk","seq":410,"time":1783352186353,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":411,"time":1783352186409,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":412,"time":1783352186410,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":413,"time":1783352186410,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":414,"time":1783352186410,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":415,"time":1783352186410,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":416,"time":1783352186436,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":417,"time":1783352186436,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":418,"time":1783352186436,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":" \\\""}}} -{"type":"assistant/chunk","seq":419,"time":1783352186436,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"HE"}}} -{"type":"assistant/chunk","seq":420,"time":1783352186436,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"LL"}}} -{"type":"assistant/chunk","seq":421,"time":1783352186437,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"O"}}} -{"type":"assistant/chunk","seq":422,"time":1783352186465,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"\\\""}}} -{"type":"assistant/chunk","seq":423,"time":1783352186465,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":424,"time":1783352186494,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":425,"time":1783352186526,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The tool is consistently being rejected by policy. The policy says \"rerun with a summary instead\" - it seems the content of the output (which would be \"HELLO\") is being blocked. Let me try a different approach - maybe I need to use a different command or approach to satisfy the policy.\n\nWait, the user explicitly asked me to \"run exactly: echo HELLO\" and \"Report the tool result you got back verbatim\". Let me try `echo \"HELLO\"` with quotes."}}}} -{"type":"assistant/chunk","seq":426,"time":1783352186527,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","arguments":"{\"description\": \"Echo the string HELLO\", \"command\": \"echo \\\"HELLO\\\"\"}"}}}} -{"type":"assistant/chunk","seq":427,"time":1783352186527,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":215,"outputTokens":176,"cacheReadTokens":3200,"reasoningTokens":106}}}} -{"type":"assistant/chunk","seq":428,"time":1783352186527,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":429,"time":1783352186527,"data":{"turn":1,"step":6,"content":[{"type":"reasoning","text":"The tool is consistently being rejected by policy. The policy says \"rerun with a summary instead\" - it seems the content of the output (which would be \"HELLO\") is being blocked. Let me try a different approach - maybe I need to use a different command or approach to satisfy the policy.\n\nWait, the user explicitly asked me to \"run exactly: echo HELLO\" and \"Report the tool result you got back verbatim\". Let me try `echo \"HELLO\"` with quotes."},{"type":"tool-call","id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","arguments":"{\"description\": \"Echo the string HELLO\", \"command\": \"echo \\\"HELLO\\\"\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":215,"outputTokens":176,"cacheReadTokens":3200,"reasoningTokens":106}},"sourceEventSeqs":[288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428],"surfaceOp":"append"} -{"type":"tool/call","seq":430,"time":1783352186527,"data":{"turn":1,"step":6,"callId":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","arguments":"{\"description\": \"Echo the string HELLO\", \"command\": \"echo \\\"HELLO\\\"\"}"}} -{"type":"hook/invoked","seq":431,"time":1783352186538,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:6","matcher":"bash"}} -{"type":"hook/result","seq":432,"time":1783352186545,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:6","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: rerun with a summary instead","durationMs":6.910448999999062}} -{"type":"tool/result","seq":433,"time":1783352186545,"data":{"turn":1,"step":6,"callId":"call_00_mGqWmySh60rWKNcyVBFk2747","content":[{"type":"text","text":"tool output rejected by policy: rerun with a summary instead"}],"isError":true},"sourceEventSeqs":[430],"surfaceOp":"append"} -{"type":"step/end","seq":434,"time":1783352186545,"data":{"turn":1,"step":6}} -{"type":"step/start","seq":435,"time":1783352186545,"data":{"turn":1,"step":7}} -{"type":"assistant/chunk","seq":436,"time":1783352187156,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":437,"time":1783352187156,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":438,"time":1783352187287,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} -{"type":"assistant/chunk","seq":439,"time":1783352187316,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":440,"time":1783352187317,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" blocking"}}} -{"type":"assistant/chunk","seq":441,"time":1783352187317,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":442,"time":1783352187345,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} -{"type":"assistant/chunk","seq":443,"time":1783352187345,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":444,"time":1783352187374,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"HE"}}} -{"type":"assistant/chunk","seq":445,"time":1783352187374,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} -{"type":"assistant/chunk","seq":446,"time":1783352187374,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} -{"type":"assistant/chunk","seq":447,"time":1783352187374,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":448,"time":1783352187374,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" -"}}} -{"type":"assistant/chunk","seq":449,"time":1783352187403,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":450,"time":1783352187403,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" seems"}}} -{"type":"assistant/chunk","seq":451,"time":1783352187432,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" like"}}} -{"type":"assistant/chunk","seq":452,"time":1783352187461,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":453,"time":1783352187461,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":454,"time":1783352187491,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":455,"time":1783352187491,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"HE"}}} -{"type":"assistant/chunk","seq":456,"time":1783352187491,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} -{"type":"assistant/chunk","seq":457,"time":1783352187491,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} -{"type":"assistant/chunk","seq":458,"time":1783352187491,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":459,"time":1783352187519,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} -{"type":"assistant/chunk","seq":460,"time":1783352187519,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"which"}}} -{"type":"assistant/chunk","seq":461,"time":1783352187548,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" might"}}} -{"type":"assistant/chunk","seq":462,"time":1783352187548,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" be"}}} -{"type":"assistant/chunk","seq":463,"time":1783352187548,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":464,"time":1783352187577,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" miss"}}} -{"type":"assistant/chunk","seq":465,"time":1783352187605,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"p"}}} -{"type":"assistant/chunk","seq":466,"time":1783352187606,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"elling"}}} -{"type":"assistant/chunk","seq":467,"time":1783352187606,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}} -{"type":"assistant/chunk","seq":468,"time":1783352187606,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":469,"time":1783352187634,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"HE"}}} -{"type":"assistant/chunk","seq":470,"time":1783352187634,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} -{"type":"assistant/chunk","seq":471,"time":1783352187635,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} -{"type":"assistant/chunk","seq":472,"time":1783352187635,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":473,"time":1783352187663,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":474,"time":1783352187663,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" looks"}}} -{"type":"assistant/chunk","seq":475,"time":1783352187692,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" like"}}} -{"type":"assistant/chunk","seq":476,"time":1783352187692,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":477,"time":1783352187692,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"HE"}}} -{"type":"assistant/chunk","seq":478,"time":1783352187721,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} -{"type":"assistant/chunk","seq":479,"time":1783352187722,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":480,"time":1783352187751,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":481,"time":1783352187751,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":482,"time":1783352187782,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} -{"type":"assistant/chunk","seq":483,"time":1783352187782,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"\")"}}} -{"type":"assistant/chunk","seq":484,"time":1783352187783,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" could"}}} -{"type":"assistant/chunk","seq":485,"time":1783352187813,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" be"}}} -{"type":"assistant/chunk","seq":486,"time":1783352187818,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" triggering"}}} -{"type":"assistant/chunk","seq":487,"time":1783352187818,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" some"}}} -{"type":"assistant/chunk","seq":488,"time":1783352187839,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" content"}}} -{"type":"assistant/chunk","seq":489,"time":1783352187839,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" filter"}}} -{"type":"assistant/chunk","seq":490,"time":1783352187839,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":491,"time":1783352187840,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":492,"time":1783352187868,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":493,"time":1783352187869,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" try"}}} -{"type":"assistant/chunk","seq":494,"time":1783352187869,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":495,"time":1783352187869,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" completely"}}} -{"type":"assistant/chunk","seq":496,"time":1783352187897,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" different"}}} -{"type":"assistant/chunk","seq":497,"time":1783352187897,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":498,"time":1783352187897,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":499,"time":1783352187926,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" see"}}} -{"type":"assistant/chunk","seq":500,"time":1783352187926,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" if"}}} -{"type":"assistant/chunk","seq":501,"time":1783352187926,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":502,"time":1783352187955,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":503,"time":1783352187983,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" works"}}} -{"type":"assistant/chunk","seq":504,"time":1783352188012,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" at"}}} -{"type":"assistant/chunk","seq":505,"time":1783352188041,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" all"}}} -{"type":"assistant/chunk","seq":506,"time":1783352188041,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":507,"time":1783352188041,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":508,"time":1783352188041,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":1,"text":"Let"}}} -{"type":"assistant/chunk","seq":509,"time":1783352188070,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":1,"text":" me"}}} -{"type":"assistant/chunk","seq":510,"time":1783352188070,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":1,"text":" try"}}} -{"type":"assistant/chunk","seq":511,"time":1783352188070,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":1,"text":" a"}}} -{"type":"assistant/chunk","seq":512,"time":1783352188071,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":1,"text":" different"}}} -{"type":"assistant/chunk","seq":513,"time":1783352188098,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":1,"text":" word"}}} -{"type":"assistant/chunk","seq":514,"time":1783352188127,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":1,"text":" to"}}} -{"type":"assistant/chunk","seq":515,"time":1783352188128,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":1,"text":" verify"}}} -{"type":"assistant/chunk","seq":516,"time":1783352188128,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":1,"text":" the"}}} -{"type":"assistant/chunk","seq":517,"time":1783352188128,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} -{"type":"assistant/chunk","seq":518,"time":1783352188157,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":1,"text":" works"}}} -{"type":"assistant/chunk","seq":519,"time":1783352188157,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":1,"text":":"}}} -{"type":"assistant/chunk","seq":520,"time":1783352188213,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":521,"time":1783352188214,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":522,"time":1783352188242,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":523,"time":1783352188243,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":524,"time":1783352188243,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":525,"time":1783352188272,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":526,"time":1783352188272,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":527,"time":1783352188272,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":528,"time":1783352188272,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"E"}}} -{"type":"assistant/chunk","seq":529,"time":1783352188299,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"cho"}}} -{"type":"assistant/chunk","seq":530,"time":1783352188300,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":" test"}}} -{"type":"assistant/chunk","seq":531,"time":1783352188328,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":" message"}}} -{"type":"assistant/chunk","seq":532,"time":1783352188360,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":533,"time":1783352188390,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":534,"time":1783352188390,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":535,"time":1783352188390,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":536,"time":1783352188390,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":537,"time":1783352188391,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":538,"time":1783352188420,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":539,"time":1783352188420,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":540,"time":1783352188420,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":" \\\""}}} -{"type":"assistant/chunk","seq":541,"time":1783352188420,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"TEST"}}} -{"type":"assistant/chunk","seq":542,"time":1783352188463,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"\\\""}}} -{"type":"assistant/chunk","seq":543,"time":1783352188463,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":544,"time":1783352188477,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":545,"time":1783352188511,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The policy is blocking the output \"HELLO\" - it seems like the word \"HELLO\" (which might be a misspelling of \"HELLO\" that looks like \"HELL\" with \"O\") could be triggering some content filter. Let me try a completely different word to see if the tool works at all."}}}} -{"type":"assistant/chunk","seq":546,"time":1783352188511,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"Let me try a different word to verify the tool works:"}}}} -{"type":"assistant/chunk","seq":547,"time":1783352188511,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","arguments":"{\"description\": \"Echo test message\", \"command\": \"echo \\\"TEST\\\"\"}"}}}} -{"type":"assistant/chunk","seq":548,"time":1783352188511,"data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":287,"outputTokens":146,"cacheReadTokens":3328,"reasoningTokens":70}}}} -{"type":"assistant/chunk","seq":549,"time":1783352188512,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":550,"time":1783352188512,"data":{"turn":1,"step":7,"content":[{"type":"reasoning","text":"The policy is blocking the output \"HELLO\" - it seems like the word \"HELLO\" (which might be a misspelling of \"HELLO\" that looks like \"HELL\" with \"O\") could be triggering some content filter. Let me try a completely different word to see if the tool works at all."},{"type":"text","text":"Let me try a different word to verify the tool works:"},{"type":"tool-call","id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","arguments":"{\"description\": \"Echo test message\", \"command\": \"echo \\\"TEST\\\"\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":287,"outputTokens":146,"cacheReadTokens":3328,"reasoningTokens":70}},"sourceEventSeqs":[436,437,438,439,440,441,442,443,444,445,446,447,448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499,500,501,502,503,504,505,506,507,508,509,510,511,512,513,514,515,516,517,518,519,520,521,522,523,524,525,526,527,528,529,530,531,532,533,534,535,536,537,538,539,540,541,542,543,544,545,546,547,548,549],"surfaceOp":"append"} -{"type":"tool/call","seq":551,"time":1783352188512,"data":{"turn":1,"step":7,"callId":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","arguments":"{\"description\": \"Echo test message\", \"command\": \"echo \\\"TEST\\\"\"}"}} -{"type":"hook/invoked","seq":552,"time":1783352188524,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:7","matcher":"bash"}} -{"type":"hook/result","seq":553,"time":1783352188532,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:7","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: rerun with a summary instead","durationMs":7.573905000001105}} -{"type":"tool/result","seq":554,"time":1783352188532,"data":{"turn":1,"step":7,"callId":"call_00_Y371DzUR0Pz6IrkO8qgB9050","content":[{"type":"text","text":"tool output rejected by policy: rerun with a summary instead"}],"isError":true},"sourceEventSeqs":[551],"surfaceOp":"append"} -{"type":"step/end","seq":555,"time":1783352188532,"data":{"turn":1,"step":7}} -{"type":"step/start","seq":556,"time":1783352188533,"data":{"turn":1,"step":8}} -{"type":"assistant/chunk","seq":557,"time":1783352189213,"data":{"turn":1,"step":8,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":558,"time":1783352189214,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":"Even"}}} -{"type":"assistant/chunk","seq":559,"time":1783352189362,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":560,"time":1783352189385,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":"TEST"}}} -{"type":"assistant/chunk","seq":561,"time":1783352189386,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":562,"time":1783352189386,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":563,"time":1783352189386,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} -{"type":"assistant/chunk","seq":564,"time":1783352189416,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":565,"time":1783352189416,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":566,"time":1783352189417,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":567,"time":1783352189417,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":" try"}}} -{"type":"assistant/chunk","seq":568,"time":1783352189443,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":569,"time":1783352189443,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":" simpler"}}} -{"type":"assistant/chunk","seq":570,"time":1783352189473,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":571,"time":1783352189474,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":" like"}}} -{"type":"assistant/chunk","seq":572,"time":1783352189502,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":573,"time":1783352189502,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":"p"}}} -{"type":"assistant/chunk","seq":574,"time":1783352189529,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":"wd"}}} -{"type":"assistant/chunk","seq":575,"time":1783352189529,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} -{"type":"assistant/chunk","seq":576,"time":1783352189529,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":" or"}}} -{"type":"assistant/chunk","seq":577,"time":1783352189529,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":578,"time":1783352189529,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":"ls"}}} -{"type":"assistant/chunk","seq":579,"time":1783352189559,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":"`."}}} -{"type":"assistant/chunk","seq":580,"time":1783352189618,"data":{"turn":1,"step":8,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":581,"time":1783352189618,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":582,"time":1783352189645,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":583,"time":1783352189646,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":584,"time":1783352189646,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":585,"time":1783352189646,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":586,"time":1783352189673,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":587,"time":1783352189674,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":588,"time":1783352189674,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":"Print"}}} -{"type":"assistant/chunk","seq":589,"time":1783352189674,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":" working"}}} -{"type":"assistant/chunk","seq":590,"time":1783352189705,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":" directory"}}} -{"type":"assistant/chunk","seq":591,"time":1783352189705,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":592,"time":1783352189734,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":593,"time":1783352189734,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":594,"time":1783352189734,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":595,"time":1783352189734,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":596,"time":1783352189763,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":597,"time":1783352189763,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":598,"time":1783352189764,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":"p"}}} -{"type":"assistant/chunk","seq":599,"time":1783352189764,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":"wd"}}} -{"type":"assistant/chunk","seq":600,"time":1783352189793,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":601,"time":1783352189793,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":602,"time":1783352189858,"data":{"turn":1,"step":8,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Even \"TEST\" is rejected. Let me try a simpler command like `pwd` or `ls`."}}}} -{"type":"assistant/chunk","seq":603,"time":1783352189858,"data":{"turn":1,"step":8,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","arguments":"{\"description\": \"Print working directory\", \"command\": \"pwd\"}"}}}} -{"type":"assistant/chunk","seq":604,"time":1783352189858,"data":{"turn":1,"step":8,"chunk":{"type":"usage","usage":{"inputTokens":201,"outputTokens":84,"cacheReadTokens":3584,"reasoningTokens":22}}}} -{"type":"assistant/chunk","seq":605,"time":1783352189858,"data":{"turn":1,"step":8,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":606,"time":1783352189859,"data":{"turn":1,"step":8,"content":[{"type":"reasoning","text":"Even \"TEST\" is rejected. Let me try a simpler command like `pwd` or `ls`."},{"type":"tool-call","id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","arguments":"{\"description\": \"Print working directory\", \"command\": \"pwd\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":201,"outputTokens":84,"cacheReadTokens":3584,"reasoningTokens":22}},"sourceEventSeqs":[557,558,559,560,561,562,563,564,565,566,567,568,569,570,571,572,573,574,575,576,577,578,579,580,581,582,583,584,585,586,587,588,589,590,591,592,593,594,595,596,597,598,599,600,601,602,603,604,605],"surfaceOp":"append"} -{"type":"tool/call","seq":607,"time":1783352189859,"data":{"turn":1,"step":8,"callId":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","arguments":"{\"description\": \"Print working directory\", \"command\": \"pwd\"}"}} -{"type":"hook/invoked","seq":608,"time":1783352189876,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:8","matcher":"bash"}} -{"type":"hook/result","seq":609,"time":1783352189883,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:8","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: rerun with a summary instead","durationMs":7.748225999999704}} -{"type":"tool/result","seq":610,"time":1783352189884,"data":{"turn":1,"step":8,"callId":"call_00_S2XENjVanaHtBn3RZfhO7965","content":[{"type":"text","text":"tool output rejected by policy: rerun with a summary instead"}],"isError":true},"sourceEventSeqs":[607],"surfaceOp":"append"} -{"type":"step/end","seq":611,"time":1783352189884,"data":{"turn":1,"step":8}} -{"type":"step/start","seq":612,"time":1783352189884,"data":{"turn":1,"step":9}} -{"type":"assistant/chunk","seq":613,"time":1783352190934,"data":{"turn":1,"step":9,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":614,"time":1783352190935,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":"All"}}} -{"type":"assistant/chunk","seq":615,"time":1783352191025,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} -{"type":"assistant/chunk","seq":616,"time":1783352191054,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" commands"}}} -{"type":"assistant/chunk","seq":617,"time":1783352191054,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" are"}}} -{"type":"assistant/chunk","seq":618,"time":1783352191054,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" being"}}} -{"type":"assistant/chunk","seq":619,"time":1783352191054,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} -{"type":"assistant/chunk","seq":620,"time":1783352191054,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":621,"time":1783352191055,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" This"}}} -{"type":"assistant/chunk","seq":622,"time":1783352191083,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" seems"}}} -{"type":"assistant/chunk","seq":623,"time":1783352191083,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" like"}}} -{"type":"assistant/chunk","seq":624,"time":1783352191083,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":625,"time":1783352191083,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" system"}}} -{"type":"assistant/chunk","seq":626,"time":1783352191111,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":"-level"}}} -{"type":"assistant/chunk","seq":627,"time":1783352191112,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} -{"type":"assistant/chunk","seq":628,"time":1783352191112,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" issue"}}} -{"type":"assistant/chunk","seq":629,"time":1783352191141,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" where"}}} -{"type":"assistant/chunk","seq":630,"time":1783352191169,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":631,"time":1783352191170,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} -{"type":"assistant/chunk","seq":632,"time":1783352191202,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":633,"time":1783352191202,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":634,"time":1783352191202,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" blocked"}}} -{"type":"assistant/chunk","seq":635,"time":1783352191230,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" entirely"}}} -{"type":"assistant/chunk","seq":636,"time":1783352191231,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":637,"time":1783352191231,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":638,"time":1783352191260,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":639,"time":1783352191260,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} -{"type":"assistant/chunk","seq":640,"time":1783352191288,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} -{"type":"assistant/chunk","seq":641,"time":1783352191289,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":642,"time":1783352191289,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":643,"time":1783352191318,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":644,"time":1783352191318,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":645,"time":1783352191319,"data":{"turn":1,"step":9,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":646,"time":1783352191319,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"I"}}} -{"type":"assistant/chunk","seq":647,"time":1783352191319,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"'m"}}} -{"type":"assistant/chunk","seq":648,"time":1783352191346,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" unable"}}} -{"type":"assistant/chunk","seq":649,"time":1783352191347,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" to"}}} -{"type":"assistant/chunk","seq":650,"time":1783352191347,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" run"}}} -{"type":"assistant/chunk","seq":651,"time":1783352191375,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" the"}}} -{"type":"assistant/chunk","seq":652,"time":1783352191376,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" `"}}} -{"type":"assistant/chunk","seq":653,"time":1783352191376,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"echo"}}} -{"type":"assistant/chunk","seq":654,"time":1783352191404,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" HE"}}} -{"type":"assistant/chunk","seq":655,"time":1783352191404,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"LL"}}} -{"type":"assistant/chunk","seq":656,"time":1783352191405,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"O"}}} -{"type":"assistant/chunk","seq":657,"time":1783352191405,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"`"}}} -{"type":"assistant/chunk","seq":658,"time":1783352191405,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" command"}}} -{"type":"assistant/chunk","seq":659,"time":1783352191405,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" via"}}} -{"type":"assistant/chunk","seq":660,"time":1783352191433,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" the"}}} -{"type":"assistant/chunk","seq":661,"time":1783352191434,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" bash"}}} -{"type":"assistant/chunk","seq":662,"time":1783352191434,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} -{"type":"assistant/chunk","seq":663,"time":1783352191434,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" —"}}} -{"type":"assistant/chunk","seq":664,"time":1783352191462,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" every"}}} -{"type":"assistant/chunk","seq":665,"time":1783352191462,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" invocation"}}} -{"type":"assistant/chunk","seq":666,"time":1783352191462,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" is"}}} -{"type":"assistant/chunk","seq":667,"time":1783352191491,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" being"}}} -{"type":"assistant/chunk","seq":668,"time":1783352191491,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" rejected"}}} -{"type":"assistant/chunk","seq":669,"time":1783352191491,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" by"}}} -{"type":"assistant/chunk","seq":670,"time":1783352191491,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" the"}}} -{"type":"assistant/chunk","seq":671,"time":1783352191491,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" policy"}}} -{"type":"assistant/chunk","seq":672,"time":1783352191525,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" with"}}} -{"type":"assistant/chunk","seq":673,"time":1783352191525,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" the"}}} -{"type":"assistant/chunk","seq":674,"time":1783352191550,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" message"}}} -{"type":"assistant/chunk","seq":675,"time":1783352191550,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" \""}}} -{"type":"assistant/chunk","seq":676,"time":1783352191550,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"tool"}}} -{"type":"assistant/chunk","seq":677,"time":1783352191578,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" output"}}} -{"type":"assistant/chunk","seq":678,"time":1783352191578,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" rejected"}}} -{"type":"assistant/chunk","seq":679,"time":1783352191578,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" by"}}} -{"type":"assistant/chunk","seq":680,"time":1783352191578,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" policy"}}} -{"type":"assistant/chunk","seq":681,"time":1783352191578,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":":"}}} -{"type":"assistant/chunk","seq":682,"time":1783352191607,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" rer"}}} -{"type":"assistant/chunk","seq":683,"time":1783352191607,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"un"}}} -{"type":"assistant/chunk","seq":684,"time":1783352191607,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" with"}}} -{"type":"assistant/chunk","seq":685,"time":1783352191607,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" a"}}} -{"type":"assistant/chunk","seq":686,"time":1783352191607,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" summary"}}} -{"type":"assistant/chunk","seq":687,"time":1783352191607,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" instead"}}} -{"type":"assistant/chunk","seq":688,"time":1783352191635,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"\"."}}} -{"type":"assistant/chunk","seq":689,"time":1783352191636,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" This"}}} -{"type":"assistant/chunk","seq":690,"time":1783352191636,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" appears"}}} -{"type":"assistant/chunk","seq":691,"time":1783352191665,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" to"}}} -{"type":"assistant/chunk","seq":692,"time":1783352191665,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" be"}}} -{"type":"assistant/chunk","seq":693,"time":1783352191665,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" a"}}} -{"type":"assistant/chunk","seq":694,"time":1783352191666,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" systemic"}}} -{"type":"assistant/chunk","seq":695,"time":1783352191666,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" policy"}}} -{"type":"assistant/chunk","seq":696,"time":1783352191692,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" restriction"}}} -{"type":"assistant/chunk","seq":697,"time":1783352191693,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" on"}}} -{"type":"assistant/chunk","seq":698,"time":1783352191693,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" the"}}} -{"type":"assistant/chunk","seq":699,"time":1783352191722,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" bash"}}} -{"type":"assistant/chunk","seq":700,"time":1783352191722,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} -{"type":"assistant/chunk","seq":701,"time":1783352191722,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" in"}}} -{"type":"assistant/chunk","seq":702,"time":1783352191751,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" this"}}} -{"type":"assistant/chunk","seq":703,"time":1783352191751,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" environment"}}} -{"type":"assistant/chunk","seq":704,"time":1783352191751,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":","}}} -{"type":"assistant/chunk","seq":705,"time":1783352191783,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" as"}}} -{"type":"assistant/chunk","seq":706,"time":1783352191811,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" even"}}} -{"type":"assistant/chunk","seq":707,"time":1783352191841,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" simple"}}} -{"type":"assistant/chunk","seq":708,"time":1783352191842,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" commands"}}} -{"type":"assistant/chunk","seq":709,"time":1783352191871,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" like"}}} -{"type":"assistant/chunk","seq":710,"time":1783352191871,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" `"}}} -{"type":"assistant/chunk","seq":711,"time":1783352191871,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"p"}}} -{"type":"assistant/chunk","seq":712,"time":1783352191871,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"wd"}}} -{"type":"assistant/chunk","seq":713,"time":1783352191871,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"`"}}} -{"type":"assistant/chunk","seq":714,"time":1783352191871,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" and"}}} -{"type":"assistant/chunk","seq":715,"time":1783352191900,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" `"}}} -{"type":"assistant/chunk","seq":716,"time":1783352191900,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"echo"}}} -{"type":"assistant/chunk","seq":717,"time":1783352191900,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" \""}}} -{"type":"assistant/chunk","seq":718,"time":1783352191900,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"TEST"}}} -{"type":"assistant/chunk","seq":719,"time":1783352191901,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"\""}}} -{"type":"assistant/chunk","seq":720,"time":1783352191927,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"`"}}} -{"type":"assistant/chunk","seq":721,"time":1783352191928,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" are"}}} -{"type":"assistant/chunk","seq":722,"time":1783352191928,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" blocked"}}} -{"type":"assistant/chunk","seq":723,"time":1783352191986,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":".\n\n"}}} -{"type":"assistant/chunk","seq":724,"time":1783352191986,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"I"}}} -{"type":"assistant/chunk","seq":725,"time":1783352191986,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" cannot"}}} -{"type":"assistant/chunk","seq":726,"time":1783352192031,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" produce"}}} -{"type":"assistant/chunk","seq":727,"time":1783352192043,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" the"}}} -{"type":"assistant/chunk","seq":728,"time":1783352192044,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} -{"type":"assistant/chunk","seq":729,"time":1783352192072,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" result"}}} -{"type":"assistant/chunk","seq":730,"time":1783352192072,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" you"}}} -{"type":"assistant/chunk","seq":731,"time":1783352192072,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" asked"}}} -{"type":"assistant/chunk","seq":732,"time":1783352192115,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" for"}}} -{"type":"assistant/chunk","seq":733,"time":1783352192116,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" because"}}} -{"type":"assistant/chunk","seq":734,"time":1783352192116,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" the"}}} -{"type":"assistant/chunk","seq":735,"time":1783352192116,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" bash"}}} -{"type":"assistant/chunk","seq":736,"time":1783352192133,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} -{"type":"assistant/chunk","seq":737,"time":1783352192133,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" is"}}} -{"type":"assistant/chunk","seq":738,"time":1783352192134,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" not"}}} -{"type":"assistant/chunk","seq":739,"time":1783352192158,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" currently"}}} -{"type":"assistant/chunk","seq":740,"time":1783352192159,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" available"}}} -{"type":"assistant/chunk","seq":741,"time":1783352192186,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" for"}}} -{"type":"assistant/chunk","seq":742,"time":1783352192215,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" use"}}} -{"type":"assistant/chunk","seq":743,"time":1783352192245,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"."}}} -{"type":"assistant/chunk","seq":744,"time":1783352192246,"data":{"turn":1,"step":9,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"All bash commands are being rejected. This seems like a system-level policy issue where the bash tool is blocked entirely. Let me report this to the user."}}}} -{"type":"assistant/chunk","seq":745,"time":1783352192246,"data":{"turn":1,"step":9,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"I'm unable to run the `echo HELLO` command via the bash tool — every invocation is being rejected by the policy with the message \"tool output rejected by policy: rerun with a summary instead\". This appears to be a systemic policy restriction on the bash tool in this environment, as even simple commands like `pwd` and `echo \"TEST\"` are blocked.\n\nI cannot produce the tool result you asked for because the bash tool is not currently available for use."}}}} -{"type":"assistant/chunk","seq":746,"time":1783352192246,"data":{"turn":1,"step":9,"chunk":{"type":"usage","usage":{"inputTokens":181,"outputTokens":130,"cacheReadTokens":3712,"reasoningTokens":31}}}} -{"type":"assistant/chunk","seq":747,"time":1783352192246,"data":{"turn":1,"step":9,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":748,"time":1783352192247,"data":{"turn":1,"step":9,"content":[{"type":"reasoning","text":"All bash commands are being rejected. This seems like a system-level policy issue where the bash tool is blocked entirely. Let me report this to the user."},{"type":"text","text":"I'm unable to run the `echo HELLO` command via the bash tool — every invocation is being rejected by the policy with the message \"tool output rejected by policy: rerun with a summary instead\". This appears to be a systemic policy restriction on the bash tool in this environment, as even simple commands like `pwd` and `echo \"TEST\"` are blocked.\n\nI cannot produce the tool result you asked for because the bash tool is not currently available for use."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":181,"outputTokens":130,"cacheReadTokens":3712,"reasoningTokens":31}},"sourceEventSeqs":[613,614,615,616,617,618,619,620,621,622,623,624,625,626,627,628,629,630,631,632,633,634,635,636,637,638,639,640,641,642,643,644,645,646,647,648,649,650,651,652,653,654,655,656,657,658,659,660,661,662,663,664,665,666,667,668,669,670,671,672,673,674,675,676,677,678,679,680,681,682,683,684,685,686,687,688,689,690,691,692,693,694,695,696,697,698,699,700,701,702,703,704,705,706,707,708,709,710,711,712,713,714,715,716,717,718,719,720,721,722,723,724,725,726,727,728,729,730,731,732,733,734,735,736,737,738,739,740,741,742,743,744,745,746,747],"surfaceOp":"append"} -{"type":"step/end","seq":749,"time":1783352192247,"data":{"turn":1,"step":9}} -{"type":"turn/end","seq":750,"time":1783352192247,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"669e8682-49fc-4dff-9bc7-6280e283cbe4","createdAt":1783962504097,"cwd":"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-WxJGUY"} +{"type":"turn/start","seq":0,"time":1783962504115,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783962504117,"data":{"content":[{"type":"text","text":"Call the bash tool to run exactly: echo HELLO. If the first tool result is rejected, retry that command once. Quote the final tool result verbatim and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783962504152,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783962504152,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783962505202,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783962505202,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783962505340,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783962505372,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783962505373,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783962505373,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783962505373,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":11,"time":1783962505466,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":12,"time":1783962505467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":13,"time":1783962505467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":14,"time":1783962505467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":15,"time":1783962505467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":16,"time":1783962505467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":17,"time":1783962505467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":18,"time":1783962505467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":19,"time":1783962505467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" HE"}}} +{"type":"assistant/chunk","seq":20,"time":1783962505467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} +{"type":"assistant/chunk","seq":21,"time":1783962505467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} +{"type":"assistant/chunk","seq":22,"time":1783962505467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":23,"time":1783962505538,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" If"}}} +{"type":"assistant/chunk","seq":24,"time":1783962505538,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":25,"time":1783962505538,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} +{"type":"assistant/chunk","seq":26,"time":1783962505538,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} +{"type":"assistant/chunk","seq":27,"time":1783962505539,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":28,"time":1783962505539,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ret"}}} +{"type":"assistant/chunk","seq":29,"time":1783962505545,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ry"}}} +{"type":"assistant/chunk","seq":30,"time":1783962505546,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} +{"type":"assistant/chunk","seq":31,"time":1783962505546,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":32,"time":1783962505546,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Then"}}} +{"type":"assistant/chunk","seq":33,"time":1783962505658,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" quote"}}} +{"type":"assistant/chunk","seq":34,"time":1783962505658,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":35,"time":1783962505658,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" final"}}} +{"type":"assistant/chunk","seq":36,"time":1783962505658,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":37,"time":1783962505658,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} +{"type":"assistant/chunk","seq":38,"time":1783962505658,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} +{"type":"assistant/chunk","seq":39,"time":1783962505658,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":40,"time":1783962505660,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":41,"time":1783962505661,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":42,"time":1783962505688,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":43,"time":1783962505688,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":44,"time":1783962505688,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":45,"time":1783962505717,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":46,"time":1783962505717,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":47,"time":1783962505717,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":48,"time":1783962505717,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":49,"time":1783962505747,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":50,"time":1783962505749,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":51,"time":1783962505749,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":52,"time":1783962505749,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":53,"time":1783962505774,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":54,"time":1783962505774,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":55,"time":1783962505804,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":56,"time":1783962505805,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":57,"time":1783962505805,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":58,"time":1783962505805,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":59,"time":1783962505834,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"E"}}} +{"type":"assistant/chunk","seq":60,"time":1783962505866,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"cho"}}} +{"type":"assistant/chunk","seq":61,"time":1783962505866,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":62,"time":1783962505866,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":63,"time":1783962505867,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":64,"time":1783962505867,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":65,"time":1783962505889,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":" stdout"}}} +{"type":"assistant/chunk","seq":66,"time":1783962505890,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":67,"time":1783962505990,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":68,"time":1783962505991,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run the bash tool with the command \"echo HELLO\". If it's rejected, retry once. Then quote the final result verbatim."}}}} +{"type":"assistant/chunk","seq":69,"time":1783962505991,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}}}} +{"type":"assistant/chunk","seq":70,"time":1783962505991,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3263,"outputTokens":103,"cacheReadTokens":0,"reasoningTokens":35}}}} +{"type":"assistant/chunk","seq":71,"time":1783962505991,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":72,"time":1783962505993,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run the bash tool with the command \"echo HELLO\". If it's rejected, retry once. Then quote the final result verbatim."},{"type":"tool-call","id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3263,"outputTokens":103,"cacheReadTokens":0,"reasoningTokens":35}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71],"surfaceOp":"append"} +{"type":"tool/call","seq":73,"time":1783962505993,"data":{"turn":1,"step":1,"callId":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}} +{"type":"hook/invoked","seq":74,"time":1783962506001,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:1","matcher":"bash"}} +{"type":"hook/result","seq":75,"time":1783962506011,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:1","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: retry once","durationMs":9.922291999999743}} +{"type":"tool/result","seq":76,"time":1783962506011,"data":{"turn":1,"step":1,"callId":"call_00_VAByyMjsct4c7P6k1ysX9256","content":[{"type":"text","text":"tool output rejected by policy: retry once"}],"isError":true},"sourceEventSeqs":[73],"surfaceOp":"append"} +{"type":"step/end","seq":77,"time":1783962506012,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":78,"time":1783962506012,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":79,"time":1783962507038,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":80,"time":1783962507038,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":81,"time":1783962507131,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":82,"time":1783962507232,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" call"}}} +{"type":"assistant/chunk","seq":83,"time":1783962507233,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":84,"time":1783962507233,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} +{"type":"assistant/chunk","seq":85,"time":1783962507233,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} +{"type":"assistant/chunk","seq":86,"time":1783962507233,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} +{"type":"assistant/chunk","seq":87,"time":1783962507233,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":88,"time":1783962507233,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":89,"time":1783962507233,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":90,"time":1783962507233,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" said"}}} +{"type":"assistant/chunk","seq":91,"time":1783962507253,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":92,"time":1783962507253,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ret"}}} +{"type":"assistant/chunk","seq":93,"time":1783962507253,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ry"}}} +{"type":"assistant/chunk","seq":94,"time":1783962507253,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} +{"type":"assistant/chunk","seq":95,"time":1783962507253,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":96,"time":1783962507253,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":97,"time":1783962507307,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":98,"time":1783962507307,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ret"}}} +{"type":"assistant/chunk","seq":99,"time":1783962507308,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ry"}}} +{"type":"assistant/chunk","seq":100,"time":1783962507308,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":101,"time":1783962507374,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":102,"time":1783962507374,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":103,"time":1783962507397,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":104,"time":1783962507397,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":105,"time":1783962507397,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":106,"time":1783962507398,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":107,"time":1783962507398,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":108,"time":1783962507426,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":109,"time":1783962507426,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":110,"time":1783962507426,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":111,"time":1783962507426,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":112,"time":1783962507455,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":113,"time":1783962507455,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":114,"time":1783962507563,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":115,"time":1783962507563,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":116,"time":1783962507564,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":117,"time":1783962507564,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":118,"time":1783962507564,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":119,"time":1783962507564,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":120,"time":1783962507564,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"E"}}} +{"type":"assistant/chunk","seq":121,"time":1783962507564,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"cho"}}} +{"type":"assistant/chunk","seq":122,"time":1783962507564,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":123,"time":1783962507565,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":124,"time":1783962507565,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":125,"time":1783962507565,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":126,"time":1783962507565,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":" stdout"}}} +{"type":"assistant/chunk","seq":127,"time":1783962507573,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":128,"time":1783962507573,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":129,"time":1783962507631,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The first call was rejected by policy. The user said to retry once. Let me retry."}}}} +{"type":"assistant/chunk","seq":130,"time":1783962507631,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}}}} +{"type":"assistant/chunk","seq":131,"time":1783962507631,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":59,"outputTokens":89,"cacheReadTokens":3328,"reasoningTokens":21}}}} +{"type":"assistant/chunk","seq":132,"time":1783962507631,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":133,"time":1783962507632,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The first call was rejected by policy. The user said to retry once. Let me retry."},{"type":"tool-call","id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":59,"outputTokens":89,"cacheReadTokens":3328,"reasoningTokens":21}},"sourceEventSeqs":[79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132],"surfaceOp":"append"} +{"type":"tool/call","seq":134,"time":1783962507632,"data":{"turn":1,"step":2,"callId":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}} +{"type":"hook/invoked","seq":135,"time":1783962507638,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:2","matcher":"bash"}} +{"type":"hook/result","seq":136,"time":1783962507659,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:2","decision":"pass","exitCode":0,"durationMs":20.260417000000416}} +{"type":"tool/result","seq":137,"time":1783962507659,"data":{"turn":1,"step":2,"callId":"call_00_tDV4j1p5eAeHTtQhXOfn6856","content":[{"type":"text","text":"HELLO\n"}],"isError":false},"sourceEventSeqs":[134],"surfaceOp":"append"} +{"type":"step/end","seq":138,"time":1783962507660,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":139,"time":1783962507660,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":140,"time":1783962508621,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":141,"time":1783962508621,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":142,"time":1783962508803,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" second"}}} +{"type":"assistant/chunk","seq":143,"time":1783962508803,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" attempt"}}} +{"type":"assistant/chunk","seq":144,"time":1783962508803,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" succeeded"}}} +{"type":"assistant/chunk","seq":145,"time":1783962508804,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":146,"time":1783962508811,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":147,"time":1783962508812,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" final"}}} +{"type":"assistant/chunk","seq":148,"time":1783962508812,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":149,"time":1783962508812,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":150,"time":1783962508839,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":151,"time":1783962508839,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"HE"}}} +{"type":"assistant/chunk","seq":152,"time":1783962508839,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} +{"type":"assistant/chunk","seq":153,"time":1783962508839,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} +{"type":"assistant/chunk","seq":154,"time":1783962508839,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":155,"time":1783962508873,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":156,"time":1783962508873,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"The"}}} +{"type":"assistant/chunk","seq":157,"time":1783962508901,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":" final"}}} +{"type":"assistant/chunk","seq":158,"time":1783962508901,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} +{"type":"assistant/chunk","seq":159,"time":1783962508901,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":" result"}}} +{"type":"assistant/chunk","seq":160,"time":1783962508902,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":" verb"}}} +{"type":"assistant/chunk","seq":161,"time":1783962508930,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"atim"}}} +{"type":"assistant/chunk","seq":162,"time":1783962508931,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":":\n\n"}}} +{"type":"assistant/chunk","seq":163,"time":1783962508931,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"```\n"}}} +{"type":"assistant/chunk","seq":164,"time":1783962508931,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"HE"}}} +{"type":"assistant/chunk","seq":165,"time":1783962508931,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"LL"}}} +{"type":"assistant/chunk","seq":166,"time":1783962508931,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"O"}}} +{"type":"assistant/chunk","seq":167,"time":1783962508983,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"\n"}}} +{"type":"assistant/chunk","seq":168,"time":1783962508984,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"```"}}} +{"type":"assistant/chunk","seq":169,"time":1783962508984,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The second attempt succeeded. The final result is \"HELLO\"."}}}} +{"type":"assistant/chunk","seq":170,"time":1783962508984,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The final tool result verbatim:\n\n```\nHELLO\n```"}}}} +{"type":"assistant/chunk","seq":171,"time":1783962508984,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":36,"outputTokens":28,"cacheReadTokens":3456,"reasoningTokens":14}}}} +{"type":"assistant/chunk","seq":172,"time":1783962508984,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":173,"time":1783962508984,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The second attempt succeeded. The final result is \"HELLO\"."},{"type":"text","text":"The final tool result verbatim:\n\n```\nHELLO\n```"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":36,"outputTokens":28,"cacheReadTokens":3456,"reasoningTokens":14}},"sourceEventSeqs":[140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172],"surfaceOp":"append"} +{"type":"step/end","seq":174,"time":1783962508984,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":175,"time":1783962508985,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.golden.jsonl index 90ab26d54e..af42092168 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.golden.jsonl @@ -1,461 +1,90 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" HE"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LL"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"O"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" using"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" If"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'s"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rejected"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ret"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ry"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" once"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Then"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" quote"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" final"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_qcIzLImnOm5qiKOBJUqY5047","title":"echo HELLO","kind":"execute","status":"in_progress","rawInput":"echo HELLO","content":[{"type":"content","content":{"type":"text","text":"Echo HELLO to stdout"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_qcIzLImnOm5qiKOBJUqY5047","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\ntool output rejected by policy: rerun with a summary instead\n```"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_VAByyMjsct4c7P6k1ysX9256","title":"echo HELLO","kind":"execute","status":"in_progress","rawInput":"echo HELLO","content":[{"type":"content","content":{"type":"text","text":"Echo HELLO to stdout"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_VAByyMjsct4c7P6k1ysX9256","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\ntool output rejected by policy: retry once\n```"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" first"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" call"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" was"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rejected"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" by"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" which"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" says"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"rer"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"un"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" summary"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instead"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" try"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" again"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" description"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" summarizes"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" what"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" we"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'re"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" doing"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_nzhKxYvCYj8OqI2tjOKb8506","title":"echo HELLO","kind":"execute","status":"in_progress","rawInput":"echo HELLO","content":[{"type":"content","content":{"type":"text","text":"Run echo HELLO command"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_nzhKxYvCYj8OqI2tjOKb8506","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\ntool output rejected by policy: rerun with a summary instead\n```"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" keeps"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rejecting"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" try"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" slightly"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" different"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" description"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_VumDDhhB4n5507EUXq650912","title":"echo HELLO","kind":"execute","status":"in_progress","rawInput":"echo HELLO","content":[{"type":"content","content":{"type":"text","text":"Print HELLO to stdout"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_VumDDhhB4n5507EUXq650912","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\ntool output rejected by policy: rerun with a summary instead\n```"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" keeps"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rejecting"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" my"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" attempts"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" try"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" very"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" short"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" description"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_i5NCqPa2LGAy1wk8uJQR2369","title":"echo HELLO","kind":"execute","status":"in_progress","rawInput":"echo HELLO","content":[{"type":"content","content":{"type":"text","text":"echo HELLO"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_i5NCqPa2LGAy1wk8uJQR2369","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\ntool output rejected by policy: rerun with a summary instead\n```"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"H"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"mm"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" keeps"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rejecting"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" try"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" without"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" description"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" field"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" just"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_0IuOIk6iuG6ZesROSyAM3669","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"echo HELLO"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_0IuOIk6iuG6ZesROSyAM3669","status":"failed","content":[{"type":"content","content":{"type":"text","text":"tool output rejected by policy: rerun with a summary instead"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" consistently"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" being"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rejected"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" by"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" says"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"rer"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"un"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" summary"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instead"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" -"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" seems"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" content"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" of"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ("}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"which"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" would"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" be"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LL"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"O"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\")"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" being"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" blocked"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" try"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" different"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" approach"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" -"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" maybe"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" different"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" or"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" approach"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" satisfy"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\n\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Wait"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" explicitly"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" said"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"run"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" echo"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" HE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LL"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"O"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Report"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" you"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" got"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" back"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ret"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ry"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" once"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" try"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LL"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"O"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" quotes"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ret"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ry"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_mGqWmySh60rWKNcyVBFk2747","title":"echo \"HELLO\"","kind":"execute","status":"in_progress","rawInput":"echo \"HELLO\"","content":[{"type":"content","content":{"type":"text","text":"Echo the string HELLO"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_mGqWmySh60rWKNcyVBFk2747","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\ntool output rejected by policy: rerun with a summary instead\n```"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_tDV4j1p5eAeHTtQhXOfn6856","title":"echo HELLO","kind":"execute","status":"in_progress","rawInput":"echo HELLO","content":[{"type":"content","content":{"type":"text","text":"Echo HELLO to stdout"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_tDV4j1p5eAeHTtQhXOfn6856","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\nHELLO\n```"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" second"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" attempt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" succeeded"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" final"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" blocking"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HE"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LL"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"O"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" -"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" seems"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" like"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LL"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"O"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ("}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"which"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" might"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" be"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" miss"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"p"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"elling"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" of"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LL"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"O"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" looks"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" like"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LL"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"O"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\")"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" could"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" be"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" triggering"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" some"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" content"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" filter"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" try"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" completely"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" different"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" see"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" if"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" works"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" at"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" all"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Let"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" try"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" different"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" word"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" verify"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" works"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_Y371DzUR0Pz6IrkO8qgB9050","title":"echo \"TEST\"","kind":"execute","status":"in_progress","rawInput":"echo \"TEST\"","content":[{"type":"content","content":{"type":"text","text":"Echo test message"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_Y371DzUR0Pz6IrkO8qgB9050","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\ntool output rejected by policy: rerun with a summary instead\n```"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Even"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"TEST"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rejected"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" try"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" simpler"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" like"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"p"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"wd"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" or"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ls"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_S2XENjVanaHtBn3RZfhO7965","title":"pwd","kind":"execute","status":"in_progress","rawInput":"pwd","content":[{"type":"content","content":{"type":"text","text":"Print working directory"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_S2XENjVanaHtBn3RZfhO7965","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\ntool output rejected by policy: rerun with a summary instead\n```"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"All"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" commands"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" are"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" being"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rejected"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" This"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" seems"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" like"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" system"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"-level"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" issue"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" where"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" blocked"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" entirely"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"'m"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" unable"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" run"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" `"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"echo"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" HE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"LL"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"O"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" command"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" via"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" bash"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" —"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" every"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" invocation"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" is"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" being"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" rejected"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" by"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" policy"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" message"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" output"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" rejected"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" by"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" policy"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" rer"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"un"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" summary"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" instead"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"\"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" This"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" appears"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" be"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" systemic"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" policy"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" restriction"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" on"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" bash"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" in"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" this"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" environment"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":","}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" as"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" even"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" simple"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" commands"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" like"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" `"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"p"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"wd"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" and"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" `"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"echo"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"TEST"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" are"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" blocked"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":".\n\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" cannot"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" produce"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" final"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tool"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" result"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" you"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" asked"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" for"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" because"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" bash"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" is"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" not"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" currently"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" available"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" for"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" use"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" verb"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"atim"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"```\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"HE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"LL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"O"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"```"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/workspace/hooks.json b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/workspace/hooks.json index f5c4fe5f3b..fd8bcf321b 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/workspace/hooks.json +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/workspace/hooks.json @@ -4,7 +4,7 @@ { "matcher": "bash", "hooks": [ - { "type": "command", "command": "echo 'tool output rejected by policy: rerun with a summary instead' >&2; exit 2" } + { "type": "command", "command": "sh posttool-once.sh" } ] } ] diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/workspace/posttool-once.sh b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/workspace/posttool-once.sh new file mode 100644 index 0000000000..2acc98bb58 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/workspace/posttool-once.sh @@ -0,0 +1,7 @@ +#!/bin/sh +if test -e .posttool-blocked; then + exit 0 +fi +: > .posttool-blocked +printf '%s\n' 'tool output rejected by policy: retry once' >&2 +exit 2 diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.golden.jsonl index e60a33bcd5..bed3d3a03a 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl index 3da0d9171e..dcd496f61c 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -55,8 +55,8 @@ {"type":"tool/call","seq":53,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} {"type":"hook/invoked","seq":54,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":55,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}} -{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"281bce21-7aee-4642-88a9-56917e50829c","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} -{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"281bce21-7aee-4642-88a9-56917e50829c","outcome":"rejected"}} +{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"9ba4bf87-d8b5-448f-94e6-6a8a0e43a8ad","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} +{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"9ba4bf87-d8b5-448f-94e6-6a8a0e43a8ad","outcome":"rejected"}} {"type":"tool/result","seq":58,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[53],"surfaceOp":"append"} {"type":"step/end","seq":59,"time":1783962235814,"data":{"turn":1,"step":1}} {"type":"step/start","seq":60,"time":1783962235814,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.golden.jsonl index a748546e51..48b5df1ac2 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.golden.jsonl index 60ae1f14e4..74f4b9ea10 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.golden.jsonl index 765dd87f7f..29023a8d45 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.golden.jsonl @@ -1,3 +1,3 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.golden.jsonl index e609fc0d70..15bf48cb81 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'s"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.golden.jsonl index c7e8beeab3..39cf7df191 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/input.json b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/input.json index 3d44990f9b..e2ddb4cc41 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/input.json +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/input.json @@ -2,6 +2,6 @@ "steps": [ { "op": "initialize" }, { "op": "newSession" }, - { "op": "prompt", "text": "Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop." } + { "op": "prompt", "text": "Call the bash tool exactly once to run: echo HELLO. Whatever tool result comes back, quote it verbatim and stop without calling another tool." } ] } diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl index 4272fe6a94..6d08c3ee7d 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl @@ -1,222 +1,118 @@ -{"type":"session","version":0,"id":"7a5183c0-ec3a-46a8-a382-475eaa0c205b","createdAt":1783352220743,"cwd":"/tmp/acp-snap-cwd-vGnYqn"} -{"type":"turn/start","seq":0,"time":1783352220747,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352220748,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783352220749,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783352220750,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783352221451,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783352221451,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783352221651,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783352221685,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783352221685,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783352221686,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783352221686,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":11,"time":1783352221686,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":12,"time":1783352221686,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":13,"time":1783352221709,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":14,"time":1783352221710,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} -{"type":"assistant/chunk","seq":15,"time":1783352221711,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" HE"}}} -{"type":"assistant/chunk","seq":16,"time":1783352221711,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} -{"type":"assistant/chunk","seq":17,"time":1783352221711,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} -{"type":"assistant/chunk","seq":18,"time":1783352221711,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":19,"time":1783352221738,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} -{"type":"assistant/chunk","seq":20,"time":1783352221738,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":21,"time":1783352221768,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} -{"type":"assistant/chunk","seq":22,"time":1783352221768,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":23,"time":1783352221768,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":24,"time":1783352221768,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} -{"type":"assistant/chunk","seq":25,"time":1783352221768,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":26,"time":1783352221768,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} -{"type":"assistant/chunk","seq":27,"time":1783352221794,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} -{"type":"assistant/chunk","seq":28,"time":1783352221795,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} -{"type":"assistant/chunk","seq":29,"time":1783352221795,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":30,"time":1783352221884,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":31,"time":1783352221885,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":32,"time":1783352221885,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":33,"time":1783352221885,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":34,"time":1783352221915,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":35,"time":1783352221915,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":36,"time":1783352221915,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":37,"time":1783352221915,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":38,"time":1783352221945,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":39,"time":1783352221945,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":" HE"}}} -{"type":"assistant/chunk","seq":40,"time":1783352221945,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"LL"}}} -{"type":"assistant/chunk","seq":41,"time":1783352221945,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"O"}}} -{"type":"assistant/chunk","seq":42,"time":1783352221945,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":43,"time":1783352222000,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":44,"time":1783352222000,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":45,"time":1783352222001,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":46,"time":1783352222001,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":47,"time":1783352222001,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":48,"time":1783352222031,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":49,"time":1783352222031,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"Print"}}} -{"type":"assistant/chunk","seq":50,"time":1783352222031,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":" HE"}}} -{"type":"assistant/chunk","seq":51,"time":1783352222031,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"LL"}}} -{"type":"assistant/chunk","seq":52,"time":1783352222031,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"O"}}} -{"type":"assistant/chunk","seq":53,"time":1783352222058,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":" to"}}} -{"type":"assistant/chunk","seq":54,"time":1783352222059,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":" stdout"}}} -{"type":"assistant/chunk","seq":55,"time":1783352222059,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":56,"time":1783352222088,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":57,"time":1783352222121,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run the command \"echo HELLO\" using the bash tool and report the result verbatim."}}}} -{"type":"assistant/chunk","seq":58,"time":1783352222121,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Print HELLO to stdout\"}"}}}} -{"type":"assistant/chunk","seq":59,"time":1783352222121,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2878,"outputTokens":92,"cacheReadTokens":0,"reasoningTokens":25}}}} -{"type":"assistant/chunk","seq":60,"time":1783352222121,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":61,"time":1783352222124,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run the command \"echo HELLO\" using the bash tool and report the result verbatim."},{"type":"tool-call","id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Print HELLO to stdout\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2878,"outputTokens":92,"cacheReadTokens":0,"reasoningTokens":25}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60],"surfaceOp":"append"} -{"type":"tool/call","seq":62,"time":1783352222124,"data":{"turn":1,"step":1,"callId":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Print HELLO to stdout\"}"}} -{"type":"hook/invoked","seq":63,"time":1783352222138,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:1","matcher":"bash"}} -{"type":"hook/result","seq":64,"time":1783352222148,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:1","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead","durationMs":9.571565000000192}} -{"type":"tool/result","seq":65,"time":1783352222148,"data":{"turn":1,"step":1,"callId":"call_00_wNJIQDMLdssZp45zIXvz2684","content":[{"type":"text","text":"tool output rejected by codex policy: summarize instead"}],"isError":true},"sourceEventSeqs":[62],"surfaceOp":"append"} -{"type":"step/end","seq":66,"time":1783352222149,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":67,"time":1783352222149,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":68,"time":1783352223151,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":69,"time":1783352223151,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":70,"time":1783352223301,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} -{"type":"assistant/chunk","seq":71,"time":1783352223315,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} -{"type":"assistant/chunk","seq":72,"time":1783352223316,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} -{"type":"assistant/chunk","seq":73,"time":1783352223316,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} -{"type":"assistant/chunk","seq":74,"time":1783352223343,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":75,"time":1783352223344,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" code"}}} -{"type":"assistant/chunk","seq":76,"time":1783352223344,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"x"}}} -{"type":"assistant/chunk","seq":77,"time":1783352223344,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} -{"type":"assistant/chunk","seq":78,"time":1783352223372,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":79,"time":1783352223406,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":80,"time":1783352223407,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instruction"}}} -{"type":"assistant/chunk","seq":81,"time":1783352223407,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":82,"time":1783352223430,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":83,"time":1783352223430,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"sum"}}} -{"type":"assistant/chunk","seq":84,"time":1783352223430,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"mar"}}} -{"type":"assistant/chunk","seq":85,"time":1783352223430,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ize"}}} -{"type":"assistant/chunk","seq":86,"time":1783352223430,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instead"}}} -{"type":"assistant/chunk","seq":87,"time":1783352223458,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":88,"time":1783352223487,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" This"}}} -{"type":"assistant/chunk","seq":89,"time":1783352223487,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" means"}}} -{"type":"assistant/chunk","seq":90,"time":1783352223488,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":91,"time":1783352223519,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" cannot"}}} -{"type":"assistant/chunk","seq":92,"time":1783352223520,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" show"}}} -{"type":"assistant/chunk","seq":93,"time":1783352223548,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":94,"time":1783352223548,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" raw"}}} -{"type":"assistant/chunk","seq":95,"time":1783352223548,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} -{"type":"assistant/chunk","seq":96,"time":1783352223548,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} -{"type":"assistant/chunk","seq":97,"time":1783352223576,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} -{"type":"assistant/chunk","seq":98,"time":1783352223576,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} -{"type":"assistant/chunk","seq":99,"time":1783352223605,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":100,"time":1783352223640,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":101,"time":1783352223641,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" requested"}}} -{"type":"assistant/chunk","seq":102,"time":1783352223641,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":103,"time":1783352223641,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":104,"time":1783352223641,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":105,"time":1783352223641,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" try"}}} -{"type":"assistant/chunk","seq":106,"time":1783352223663,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":107,"time":1783352223664,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" comply"}}} -{"type":"assistant/chunk","seq":108,"time":1783352223691,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" but"}}} -{"type":"assistant/chunk","seq":109,"time":1783352223692,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} -{"type":"assistant/chunk","seq":110,"time":1783352223721,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} -{"type":"assistant/chunk","seq":111,"time":1783352223721,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" happened"}}} -{"type":"assistant/chunk","seq":112,"time":1783352223749,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\n\n"}}} -{"type":"assistant/chunk","seq":113,"time":1783352223749,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Actually"}}} -{"type":"assistant/chunk","seq":114,"time":1783352223750,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":115,"time":1783352223750,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":116,"time":1783352223750,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":117,"time":1783352223750,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} -{"type":"assistant/chunk","seq":118,"time":1783352223778,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":119,"time":1783352223779,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":120,"time":1783352223779,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} -{"type":"assistant/chunk","seq":121,"time":1783352223813,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":122,"time":1783352223813,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":123,"time":1783352223837,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} -{"type":"assistant/chunk","seq":124,"time":1783352223837,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} -{"type":"assistant/chunk","seq":125,"time":1783352223837,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} -{"type":"assistant/chunk","seq":126,"time":1783352223837,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":127,"time":1783352223865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} -{"type":"assistant/chunk","seq":128,"time":1783352223866,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":129,"time":1783352223866,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} -{"type":"assistant/chunk","seq":130,"time":1783352223896,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":131,"time":1783352223897,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" got"}}} -{"type":"assistant/chunk","seq":132,"time":1783352223923,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" back"}}} -{"type":"assistant/chunk","seq":133,"time":1783352223923,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":134,"time":1783352223952,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":\n\n"}}} -{"type":"assistant/chunk","seq":135,"time":1783352223953,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":136,"time":1783352223953,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"tool"}}} -{"type":"assistant/chunk","seq":137,"time":1783352223953,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} -{"type":"assistant/chunk","seq":138,"time":1783352223953,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} -{"type":"assistant/chunk","seq":139,"time":1783352223953,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} -{"type":"assistant/chunk","seq":140,"time":1783352223953,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" code"}}} -{"type":"assistant/chunk","seq":141,"time":1783352223981,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"x"}}} -{"type":"assistant/chunk","seq":142,"time":1783352223981,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} -{"type":"assistant/chunk","seq":143,"time":1783352223981,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} -{"type":"assistant/chunk","seq":144,"time":1783352223981,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" summarize"}}} -{"type":"assistant/chunk","seq":145,"time":1783352223981,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instead"}}} -{"type":"assistant/chunk","seq":146,"time":1783352224017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"\n\n"}}} -{"type":"assistant/chunk","seq":147,"time":1783352224017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"So"}}} -{"type":"assistant/chunk","seq":148,"time":1783352224017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":149,"time":1783352224018,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} -{"type":"assistant/chunk","seq":150,"time":1783352224039,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} -{"type":"assistant/chunk","seq":151,"time":1783352224039,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":152,"time":1783352224039,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} -{"type":"assistant/chunk","seq":153,"time":1783352224068,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} -{"type":"assistant/chunk","seq":154,"time":1783352224068,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":155,"time":1783352224068,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":156,"time":1783352224068,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"The"}}} -{"type":"assistant/chunk","seq":157,"time":1783352224068,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} -{"type":"assistant/chunk","seq":158,"time":1783352224105,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" result"}}} -{"type":"assistant/chunk","seq":159,"time":1783352224105,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" I"}}} -{"type":"assistant/chunk","seq":160,"time":1783352224105,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" got"}}} -{"type":"assistant/chunk","seq":161,"time":1783352224105,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" back"}}} -{"type":"assistant/chunk","seq":162,"time":1783352224129,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" is"}}} -{"type":"assistant/chunk","seq":163,"time":1783352224159,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":\n\n"}}} -{"type":"assistant/chunk","seq":164,"time":1783352224159,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":">"}}} -{"type":"assistant/chunk","seq":165,"time":1783352224186,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" `"}}} -{"type":"assistant/chunk","seq":166,"time":1783352224186,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"tool"}}} -{"type":"assistant/chunk","seq":167,"time":1783352224187,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" output"}}} -{"type":"assistant/chunk","seq":168,"time":1783352224215,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" rejected"}}} -{"type":"assistant/chunk","seq":169,"time":1783352224216,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" by"}}} -{"type":"assistant/chunk","seq":170,"time":1783352224216,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" code"}}} -{"type":"assistant/chunk","seq":171,"time":1783352224216,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"x"}}} -{"type":"assistant/chunk","seq":172,"time":1783352224216,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" policy"}}} -{"type":"assistant/chunk","seq":173,"time":1783352224216,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":"}}} -{"type":"assistant/chunk","seq":174,"time":1783352224244,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" summarize"}}} -{"type":"assistant/chunk","seq":175,"time":1783352224244,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" instead"}}} -{"type":"assistant/chunk","seq":176,"time":1783352224244,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"`\n\n"}}} -{"type":"assistant/chunk","seq":177,"time":1783352224245,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"This"}}} -{"type":"assistant/chunk","seq":178,"time":1783352224245,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" is"}}} -{"type":"assistant/chunk","seq":179,"time":1783352224297,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} -{"type":"assistant/chunk","seq":180,"time":1783352224297,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" verb"}}} -{"type":"assistant/chunk","seq":181,"time":1783352224305,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"atim"}}} -{"type":"assistant/chunk","seq":182,"time":1783352224305,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" result"}}} -{"type":"assistant/chunk","seq":183,"time":1783352224305,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" from"}}} -{"type":"assistant/chunk","seq":184,"time":1783352224305,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} -{"type":"assistant/chunk","seq":185,"time":1783352224331,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} -{"type":"assistant/chunk","seq":186,"time":1783352224360,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" —"}}} -{"type":"assistant/chunk","seq":187,"time":1783352224361,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" it"}}} -{"type":"assistant/chunk","seq":188,"time":1783352224361,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" was"}}} -{"type":"assistant/chunk","seq":189,"time":1783352224392,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" not"}}} -{"type":"assistant/chunk","seq":190,"time":1783352224418,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} -{"type":"assistant/chunk","seq":191,"time":1783352224446,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" expected"}}} -{"type":"assistant/chunk","seq":192,"time":1783352224476,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" `"}}} -{"type":"assistant/chunk","seq":193,"time":1783352224476,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"\""}}} -{"type":"assistant/chunk","seq":194,"time":1783352224476,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"HE"}}} -{"type":"assistant/chunk","seq":195,"time":1783352224505,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"LL"}}} -{"type":"assistant/chunk","seq":196,"time":1783352224505,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"O"}}} -{"type":"assistant/chunk","seq":197,"time":1783352224506,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"\""}}} -{"type":"assistant/chunk","seq":198,"time":1783352224506,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"`"}}} -{"type":"assistant/chunk","seq":199,"time":1783352224506,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" output"}}} -{"type":"assistant/chunk","seq":200,"time":1783352224506,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" because"}}} -{"type":"assistant/chunk","seq":201,"time":1783352224533,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} -{"type":"assistant/chunk","seq":202,"time":1783352224534,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" code"}}} -{"type":"assistant/chunk","seq":203,"time":1783352224534,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"x"}}} -{"type":"assistant/chunk","seq":204,"time":1783352224563,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" policy"}}} -{"type":"assistant/chunk","seq":205,"time":1783352224563,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" blocked"}}} -{"type":"assistant/chunk","seq":206,"time":1783352224563,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" it"}}} -{"type":"assistant/chunk","seq":207,"time":1783352224592,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" and"}}} -{"type":"assistant/chunk","seq":208,"time":1783352224621,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" instructed"}}} -{"type":"assistant/chunk","seq":209,"time":1783352224621,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" me"}}} -{"type":"assistant/chunk","seq":210,"time":1783352224653,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" to"}}} -{"type":"assistant/chunk","seq":211,"time":1783352224654,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" summarize"}}} -{"type":"assistant/chunk","seq":212,"time":1783352224654,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" instead"}}} -{"type":"assistant/chunk","seq":213,"time":1783352224654,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"."}}} -{"type":"assistant/chunk","seq":214,"time":1783352224654,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The output was rejected by the codex policy with the instruction to \"summarize instead\". This means I cannot show the raw output verbatim as the user requested. Let me try to comply but report what happened.\n\nActually, the user asked me to report the tool result verbatim. The tool result I got back is:\n\n\"tool output rejected by codex policy: summarize instead\"\n\nSo I should report that verbatim."}}}} -{"type":"assistant/chunk","seq":215,"time":1783352224654,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool result I got back is:\n\n> `tool output rejected by codex policy: summarize instead`\n\nThis is the verbatim result from the tool — it was not the expected `\"HELLO\"` output because the codex policy blocked it and instructed me to summarize instead."}}}} -{"type":"assistant/chunk","seq":216,"time":1783352224654,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":176,"outputTokens":145,"cacheReadTokens":2816,"reasoningTokens":86}}}} -{"type":"assistant/chunk","seq":217,"time":1783352224654,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":218,"time":1783352224655,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The output was rejected by the codex policy with the instruction to \"summarize instead\". This means I cannot show the raw output verbatim as the user requested. Let me try to comply but report what happened.\n\nActually, the user asked me to report the tool result verbatim. The tool result I got back is:\n\n\"tool output rejected by codex policy: summarize instead\"\n\nSo I should report that verbatim."},{"type":"text","text":"The tool result I got back is:\n\n> `tool output rejected by codex policy: summarize instead`\n\nThis is the verbatim result from the tool — it was not the expected `\"HELLO\"` output because the codex policy blocked it and instructed me to summarize instead."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":176,"outputTokens":145,"cacheReadTokens":2816,"reasoningTokens":86}},"sourceEventSeqs":[68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217],"surfaceOp":"append"} -{"type":"step/end","seq":219,"time":1783352224655,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":220,"time":1783352224655,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"01aa6a36-e9c2-42ba-934b-30bec80a1658","createdAt":1783986962232,"cwd":"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-x67BsP"} +{"type":"turn/start","seq":0,"time":1783986962235,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783986962235,"data":{"content":[{"type":"text","text":"Call the bash tool exactly once to run: echo HELLO. Whatever tool result comes back, quote it verbatim and stop without calling another tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783986962240,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783986962240,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783986962953,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783986962953,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783986963134,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783986963134,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783986963134,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783986963134,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783986963134,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" call"}}} +{"type":"assistant/chunk","seq":11,"time":1783986963134,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":12,"time":1783986963134,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":13,"time":1783986963134,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":14,"time":1783986963135,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} +{"type":"assistant/chunk","seq":15,"time":1783986963135,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":16,"time":1783986963135,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":17,"time":1783986963160,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":18,"time":1783986963213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" HE"}}} +{"type":"assistant/chunk","seq":19,"time":1783986963213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} +{"type":"assistant/chunk","seq":20,"time":1783986963213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} +{"type":"assistant/chunk","seq":21,"time":1783986963213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`,"}}} +{"type":"assistant/chunk","seq":22,"time":1783986963213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":23,"time":1783986963213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" quote"}}} +{"type":"assistant/chunk","seq":24,"time":1783986963213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":25,"time":1783986963221,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":26,"time":1783986963221,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} +{"type":"assistant/chunk","seq":27,"time":1783986963221,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} +{"type":"assistant/chunk","seq":28,"time":1783986963221,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":29,"time":1783986963252,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":30,"time":1783986963252,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":31,"time":1783986963314,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":32,"time":1783986963315,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":33,"time":1783986963345,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":34,"time":1783986963345,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":35,"time":1783986963345,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":36,"time":1783986963369,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":37,"time":1783986963369,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":38,"time":1783986963369,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":39,"time":1783986963369,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":40,"time":1783986963397,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":41,"time":1783986963397,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":42,"time":1783986963397,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":43,"time":1783986963397,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":44,"time":1783986963428,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":45,"time":1783986963429,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":46,"time":1783986963457,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":47,"time":1783986963457,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":48,"time":1783986963457,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":49,"time":1783986963457,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":50,"time":1783986963489,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"E"}}} +{"type":"assistant/chunk","seq":51,"time":1783986963514,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"cho"}}} +{"type":"assistant/chunk","seq":52,"time":1783986963514,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":53,"time":1783986963514,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":54,"time":1783986963514,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":55,"time":1783986963514,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":56,"time":1783986963544,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":" stdout"}}} +{"type":"assistant/chunk","seq":57,"time":1783986963544,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":58,"time":1783986963658,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":59,"time":1783986963659,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to call the bash tool once with `echo HELLO`, then quote the result verbatim and stop."}}}} +{"type":"assistant/chunk","seq":60,"time":1783986963660,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}}}} +{"type":"assistant/chunk","seq":61,"time":1783986963660,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3256,"outputTokens":94,"cacheReadTokens":0,"reasoningTokens":26}}}} +{"type":"assistant/chunk","seq":62,"time":1783986963660,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":63,"time":1783986963663,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to call the bash tool once with `echo HELLO`, then quote the result verbatim and stop."},{"type":"tool-call","id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3256,"outputTokens":94,"cacheReadTokens":0,"reasoningTokens":26}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62],"surfaceOp":"append"} +{"type":"tool/call","seq":64,"time":1783986963664,"data":{"turn":1,"step":1,"callId":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}} +{"type":"hook/invoked","seq":65,"time":1783986963673,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:1","matcher":"bash"}} +{"type":"hook/result","seq":66,"time":1783986963677,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:1","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead","durationMs":4.42941699999983}} +{"type":"tool/result","seq":67,"time":1783986963678,"data":{"turn":1,"step":1,"callId":"call_00_1rmSWHhVchVg7PDTmegT0421","content":[{"type":"text","text":"tool output rejected by codex policy: summarize instead"}],"isError":true},"sourceEventSeqs":[64],"surfaceOp":"append"} +{"type":"step/end","seq":68,"time":1783986963678,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":69,"time":1783986963679,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":70,"time":1783986964555,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":71,"time":1783986964555,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":72,"time":1783986964809,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":73,"time":1783986964835,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":74,"time":1783986964835,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":75,"time":1783986964836,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} +{"type":"assistant/chunk","seq":76,"time":1783986964864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} +{"type":"assistant/chunk","seq":77,"time":1783986964865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" code"}}} +{"type":"assistant/chunk","seq":78,"time":1783986964865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"x"}}} +{"type":"assistant/chunk","seq":79,"time":1783986964893,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} +{"type":"assistant/chunk","seq":80,"time":1783986964899,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":81,"time":1783986964900,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":82,"time":1783986964924,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":83,"time":1783986964924,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" quote"}}} +{"type":"assistant/chunk","seq":84,"time":1783986964955,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} +{"type":"assistant/chunk","seq":85,"time":1783986964985,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":86,"time":1783986965013,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" got"}}} +{"type":"assistant/chunk","seq":87,"time":1783986965014,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" back"}}} +{"type":"assistant/chunk","seq":88,"time":1783986965045,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":89,"time":1783986965132,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":90,"time":1783986965132,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"```\n"}}} +{"type":"assistant/chunk","seq":91,"time":1783986965133,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"<"}}} +{"type":"assistant/chunk","seq":92,"time":1783986965133,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"tool"}}} +{"type":"assistant/chunk","seq":93,"time":1783986965133,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_result"}}} +{"type":"assistant/chunk","seq":94,"time":1783986965133,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":">"}}} +{"type":"assistant/chunk","seq":95,"time":1783986965233,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"tool"}}} +{"type":"assistant/chunk","seq":96,"time":1783986965234,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" output"}}} +{"type":"assistant/chunk","seq":97,"time":1783986965235,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" rejected"}}} +{"type":"assistant/chunk","seq":98,"time":1783986965235,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" by"}}} +{"type":"assistant/chunk","seq":99,"time":1783986965235,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" code"}}} +{"type":"assistant/chunk","seq":100,"time":1783986965235,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"x"}}} +{"type":"assistant/chunk","seq":101,"time":1783986965235,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" policy"}}} +{"type":"assistant/chunk","seq":102,"time":1783986965235,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":"}}} +{"type":"assistant/chunk","seq":103,"time":1783986965236,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" summarize"}}} +{"type":"assistant/chunk","seq":104,"time":1783986965236,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" instead"}}} +{"type":"assistant/chunk","seq":105,"time":1783986965236,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"\n"}}} +{"type":"assistant/chunk","seq":109,"time":1783986965237,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"```"}}} +{"type":"assistant/chunk","seq":110,"time":1783986965237,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The tool output was rejected by codex policy. Let me quote what I got back."}}}} +{"type":"assistant/chunk","seq":111,"time":1783986965237,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"```\ntool output rejected by codex policy: summarize instead\n```"}}}} +{"type":"assistant/chunk","seq":112,"time":1783986965237,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":44,"outputTokens":39,"cacheReadTokens":3328,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":113,"time":1783986965237,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":114,"time":1783986965238,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The tool output was rejected by codex policy. Let me quote what I got back."},{"type":"text","text":"```\ntool output rejected by codex policy: summarize instead\n```"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":44,"outputTokens":39,"cacheReadTokens":3328,"reasoningTokens":18}},"sourceEventSeqs":[70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113],"surfaceOp":"append"} +{"type":"step/end","seq":115,"time":1783986965238,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":116,"time":1783986965238,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl index 6257014596..7870b73dc2 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl @@ -1,128 +1,56 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" call"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" once"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" HE"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LL"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"O"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" using"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`,"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" then"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" quote"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_wNJIQDMLdssZp45zIXvz2684","title":"echo HELLO","kind":"execute","status":"in_progress","rawInput":"echo HELLO","content":[{"type":"content","content":{"type":"text","text":"Print HELLO to stdout"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_wNJIQDMLdssZp45zIXvz2684","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\ntool output rejected by codex policy: summarize instead\n```"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_1rmSWHhVchVg7PDTmegT0421","title":"echo HELLO","kind":"execute","status":"in_progress","rawInput":"echo HELLO","content":[{"type":"content","content":{"type":"text","text":"Echo HELLO to stdout"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_1rmSWHhVchVg7PDTmegT0421","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\ntool output rejected by codex policy: summarize instead\n```"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" was"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rejected"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" by"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" code"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"x"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instruction"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"sum"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"mar"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ize"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instead"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" This"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" means"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" cannot"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" show"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" raw"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" as"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" requested"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" try"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" comply"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" but"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" quote"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" what"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" happened"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\n\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Actually"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" got"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" back"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":\n\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rejected"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" by"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" code"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"x"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" summarize"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instead"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"\n\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"So"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" should"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" result"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" got"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" back"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" is"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"```\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"<"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"_result"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":">"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" `"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"tool"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" output"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" rejected"}}}} @@ -133,42 +61,9 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" summarize"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" instead"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`\n\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"This"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" is"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" verb"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"atim"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" result"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" from"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" —"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" it"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" was"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" not"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" expected"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" `"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"HE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"LL"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"O"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" output"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" because"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" code"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"x"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" policy"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" blocked"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" it"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" and"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" instructed"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" summarize"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" instead"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"```"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.golden.jsonl index 55310f6b3a..cece2795f7 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.golden.jsonl index 8eba2d862e..1a0e83d191 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.golden.jsonl index 765dd87f7f..29023a8d45 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.golden.jsonl @@ -1,3 +1,3 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.golden.jsonl index 76140724de..15a91d1af3 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.golden.jsonl index af9959379d..821d648791 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/model-switching/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/model-switching/stdout.golden.jsonl index 011d06f5d2..291525f825 100644 --- a/examples/acp-agent/tests/snapshots/model-switching/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/model-switching/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} @@ -23,7 +23,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"FL"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ASH"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} -{"jsonrpc":"2.0","id":4,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-pro\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":4,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-pro\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/multi-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/multi-turn/stdout.golden.jsonl index 5c38cacf55..9a55a86f02 100644 --- a/examples/acp-agent/tests/snapshots/multi-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/multi-turn/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.golden.jsonl index 6d7bc199e8..8680db3d30 100644 --- a/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_read_a","title":"Read a.txt","kind":"read","status":"in_progress","locations":[{"path":"a.txt","line":1}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_read_b","title":"Read b.txt","kind":"read","status":"in_progress","locations":[{"path":"b.txt","line":1}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_read_a","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/permission-switching/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/permission-switching/stdout.golden.jsonl index be9ca10cf5..358e81f076 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/permission-switching/stdout.golden.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} -{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} @@ -44,7 +44,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"D"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}} {"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn"}} -{"jsonrpc":"2.0","id":5,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":5,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.golden.jsonl index 3a900cfe34..8469933a94 100644 --- a/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_1","title":"Update todo list","kind":"other","status":"in_progress","rawInput":[{"content":"watch the kettle boil","status":"in_progress"}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"plan","entries":[{"content":"watch the kettle boil","priority":"medium","status":"in_progress"}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}}]}}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/skill-load/stdout.golden.jsonl index 6a3dfcc4c4..a1b4b8c0cb 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Load the requested skill."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_skill_load","title":"Load skill snapshot-skill","kind":"read","status":"in_progress","rawInput":"snapshot-skill"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_skill_load","status":"completed","content":[{"type":"content","content":{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/snapshot-skill\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\nFollow these snapshot-only instructions.\nResolve referenced resources relative to this skill directory.\n\n"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl index 0574f2e6c4..e2941dd851 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl index bfc195aa36..e5cc8bfa90 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl index 7f9d2c51fa..bd4fb81d4a 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl index 4a07c313e0..2b77e856e6 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl index 29528d9984..c717c3182a 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/todo-plan/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/todo-plan/stdout.golden.jsonl index fb2cd46879..8771e50182 100644 --- a/examples/acp-agent/tests/snapshots/todo-plan/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/todo-plan/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.golden.jsonl index 6abf7023b5..2c19d8feb9 100644 --- a/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl index 4566071090..03f482bcc6 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/stdout.golden.jsonl index f99eb73eef..a55c6d6e01 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_workspace_read","title":"Read nested/task.txt","kind":"read","status":"in_progress","locations":[{"path":"nested/task.txt","line":1}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_workspace_read","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl index 1c76f1ac61..4e8db74ba6 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/coding-agent/tests/code-mode.e2e.ts b/examples/coding-agent/tests/code-mode.e2e.ts index 4a4fdc0980..e243270e29 100644 --- a/examples/coding-agent/tests/code-mode.e2e.ts +++ b/examples/coding-agent/tests/code-mode.e2e.ts @@ -8,9 +8,9 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -75,7 +75,7 @@ async function workspaceCodeModeHarness(): Promise { return harness } -function waitForIdle(harness: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(harness: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = harness.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -90,7 +90,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p it('collapses the wire tool list to [run_code], bridges sub-calls, and returns curated output', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-code-mode-e2e-')) ctx = await codeModeHarness(workdir) - const agent = ctx.agentLoop.create(AgentId('e2e-code-mode'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('e2e-code-mode'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', @@ -139,7 +139,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p await writeFile(join(workdir, 'pkg/deep/task.txt'), 'Touch this file to discover the nested instructions.\n') ctx = await workspaceCodeModeHarness() const handle = await ctx.agents.create({ - agentId: AgentId('e2e-code-mode-workspace'), sessionId: SessionId('e2e-code-mode-workspace-session'), meta: { cwd: workdir }, agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, @@ -149,7 +148,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p type: 'text', text: 'Use one run_code program to call tools.read on pkg/deep/task.txt. After it finishes, answer: Code Mode workspace handshake?', }]) - await waitForIdle(ctx, handle.agent as ReactLoopAgent) + await waitForIdle(ctx, handle.agent) const events: SessionEvent[] = [...handle.agent.session.events] const dispatch = events.find(event => event.type === 'tool/code-dispatch' && event.data.name === 'read') diff --git a/examples/coding-agent/tests/coding-task.e2e.ts b/examples/coding-agent/tests/coding-task.e2e.ts index d53688835e..a5f525e5c3 100644 --- a/examples/coding-agent/tests/coding-task.e2e.ts +++ b/examples/coding-agent/tests/coding-task.e2e.ts @@ -4,8 +4,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * The swebench-style smoke test: a real model fixes a real bug in a temp @@ -54,7 +54,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('coding task: fix a failing test expect(before.status).not.toBe(0) ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT }) - const agent = ctx.agentLoop.create(AgentId('e2e-task'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('e2e-task'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', diff --git a/examples/coding-agent/tests/compaction.e2e.ts b/examples/coding-agent/tests/compaction.e2e.ts index 6ba2908ad3..d992fc9efa 100644 --- a/examples/coding-agent/tests/compaction.e2e.ts +++ b/examples/coding-agent/tests/compaction.e2e.ts @@ -3,8 +3,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * Key-gated smoke for mid-session compaction. It verifies the compact event @@ -46,7 +46,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa }, persistenceRoot: join(workdir, '.sessions'), }) - const agent = ctx.agentLoop.create(AgentId('e2e-compaction'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('e2e-compaction'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', diff --git a/examples/coding-agent/tests/full-loop.e2e.ts b/examples/coding-agent/tests/full-loop.e2e.ts index 7314b7ab21..db2eec63fc 100644 --- a/examples/coding-agent/tests/full-loop.e2e.ts +++ b/examples/coding-agent/tests/full-loop.e2e.ts @@ -3,8 +3,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * The first place a REAL model meets the REAL bash tool: the cheap canary @@ -28,7 +28,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('full loop: real model + real bas it('runs a bash command on request and reports its output', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-full-loop-e2e-')) ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT }) - const agent = ctx.agentLoop.create(AgentId('e2e-loop'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('e2e-loop'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }]) await waitForIdle(ctx, agent) diff --git a/examples/coding-agent/tests/harness.ts b/examples/coding-agent/tests/harness.ts index 944d01a0b9..290ffcdf0b 100644 --- a/examples/coding-agent/tests/harness.ts +++ b/examples/coding-agent/tests/harness.ts @@ -1,6 +1,7 @@ import { Context } from 'cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' @@ -71,7 +72,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio return ctx } -export function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +export function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { diff --git a/examples/coding-agent/tests/resume.e2e.ts b/examples/coding-agent/tests/resume.e2e.ts index c9081c659f..01c7d52393 100644 --- a/examples/coding-agent/tests/resume.e2e.ts +++ b/examples/coding-agent/tests/resume.e2e.ts @@ -3,8 +3,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' -import type { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' -import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' @@ -40,10 +38,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses // log on disk survives. ctx = await codingHarness(process.cwd(), { persona: SYSTEM_PROMPT, persistenceRoot: root }) const first = (await ctx.agents.create({ - agentId: AgentId('resume-1'), sessionId: SESSION_ID, agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, - })).agent as ReactLoopAgent + })).agent first.send([{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }]) await waitForIdle(ctx, first) await ctx.fiber.dispose() @@ -54,10 +51,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses // run 1's exchange as conversation history. ctx = await codingHarness(process.cwd(), { persona: SYSTEM_PROMPT, persistenceRoot: root }) const resumed = (await ctx.agents.resume({ - agentId: AgentId('resume-2'), resumeSessionId: SESSION_ID, agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, - })).agent as ReactLoopAgent + })).agent expect(resumed.session.id).toBe(SESSION_ID) // The prior user turn is in the rehydrated log before the model is asked. expect(JSON.stringify(resumed.session.deriveMessages())).toContain(SECRET) diff --git a/examples/coding-agent/tests/todo-write.e2e.ts b/examples/coding-agent/tests/todo-write.e2e.ts index a16f15528d..daf5c018b1 100644 --- a/examples/coding-agent/tests/todo-write.e2e.ts +++ b/examples/coding-agent/tests/todo-write.e2e.ts @@ -3,8 +3,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' import { codingHarness, TODO_SYSTEM_PROMPT, waitForIdle } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * A REAL model drives the REAL todo_write tool: verify the WORLD (the session @@ -26,7 +26,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('todo_write: real model records a it('appends a todo/write event with the model-produced task list', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-todo-write-e2e-')) ctx = await codingHarness(workdir, { persona: TODO_SYSTEM_PROMPT }) - const agent = ctx.agentLoop.create(AgentId('e2e-todo'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('e2e-todo'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', text: 'Use the todo_write tool to record a plan of exactly two steps: first ' diff --git a/examples/cordis-agent/tests/cordis-tools.e2e.ts b/examples/cordis-agent/tests/cordis-tools.e2e.ts index 68fe88038b..f12f85b6ca 100644 --- a/examples/cordis-agent/tests/cordis-tools.e2e.ts +++ b/examples/cordis-agent/tests/cordis-tools.e2e.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' -import { AgentId } from '@deepseek-ai/dsh-agent' import { cordisHarness, waitForIdle } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * With-key smoke for the self-referential cordis tools: a REAL model drives @@ -38,7 +38,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif it('mounts a status listener whose tagged output actually fires, then unmounts it', async () => { ctx = await cordisHarness() const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - const agent = ctx.agentLoop.create(AgentId('cordis-e2e-listener'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('cordis-e2e-listener'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', @@ -66,7 +66,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif it('builds itself a reverse_text tool and actually calls it', async () => { ctx = await cordisHarness() - const agent = ctx.agentLoop.create(AgentId('cordis-e2e-selftool'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('cordis-e2e-selftool'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', @@ -113,7 +113,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif it('composes two mounts through provide/inject, and unmounting the provider parks the consumer', async () => { ctx = await cordisHarness() - const agent = ctx.agentLoop.create(AgentId('cordis-e2e-compose'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('cordis-e2e-compose'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', diff --git a/examples/cordis-agent/tests/harness.ts b/examples/cordis-agent/tests/harness.ts index 19868af14c..014ce74f7c 100644 --- a/examples/cordis-agent/tests/harness.ts +++ b/examples/cordis-agent/tests/harness.ts @@ -1,5 +1,6 @@ import { Context } from 'cordis' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' @@ -28,7 +29,7 @@ export async function cordisHarness(): Promise { return ctx } -export function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +export function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { diff --git a/knip.json b/knip.json index ad83bbbd2e..d64b8bb11b 100644 --- a/knip.json +++ b/knip.json @@ -105,6 +105,10 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/ui/jsonrpc": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, "packages/examples/stdio-demo": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index 1f9055c67a..d01167de3d 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -5,8 +5,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' -import { AgentId } from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import TaskService from '@deepseek-ai/dsh-tasks' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' @@ -39,7 +39,7 @@ afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) }) -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -50,7 +50,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } -function events(agent: ReactLoopAgent): SessionEvent[] { +function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } @@ -100,11 +100,10 @@ describe('bash tool through the agent loop', () => { ]) const ctx = await harness(adapter, root, dshHome) const handle = await ctx.agents.create({ - agentId: AgentId('session-env'), sessionId: SessionId('session-env-id'), agentOptions: { provider: 'mock', model: 'mock' }, }) - const agent = handle.agent as ReactLoopAgent + const agent = handle.agent const location = ctx.sessionPersistence.locate(agent.session.header) expect(location?.kind).toBe('jsonl') @@ -125,7 +124,7 @@ describe('bash tool through the agent loop', () => { textResponse('The command printed integration-ok.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('it-fg'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('it-fg'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'run echo integration-ok' }]) await waitForIdle(ctx, agent) @@ -157,7 +156,7 @@ describe('bash tool through the agent loop', () => { textResponse('It failed with code 9.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('it-exit'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('it-exit'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'run exit 9' }]) await waitForIdle(ctx, agent) @@ -177,7 +176,7 @@ describe('bash tool through the agent loop', () => { textResponse('Background task finished.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('it-bg'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('it-bg'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'run echo bg-ok in the background' }]) await waitForIdle(ctx, agent) diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index c19846ab13..af0cdcaa1c 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -10,7 +10,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import TaskService from '@deepseek-ai/dsh-tasks' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' @@ -50,18 +50,17 @@ async function setupWithTasks() { } /** - * Build a fake {@link Agent} whose session token is `sessionId`, give it a + * Build a fake {@link Agent} with the shared agent/session identity, give it a * dedicated lifecycle fiber for `Agent.ctx`, and register it in `ctx.agents`. - * The agent id is deliberately different from the session token so a - * wrong-field ownership match fails the test. */ function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Agent { const scopeFiber = ctx.plugin(() => {}) + const id = SessionId(sessionId) const agent = { - id: `agent-${sessionId}`, + id, ctx: scopeFiber.ctx, inject, - session: { header: { version: 0, id: sessionId, createdAt: 0 } }, + session: { id, header: { version: 0, id, createdAt: 0 } }, } as unknown as Agent ctx.agents.register(agent) return agent @@ -183,11 +182,13 @@ async function setupSandboxed(withApproval = false) { function sandboxAgent(mode?: 'read-only' | 'workspace-write' | 'danger-full-access', ctx?: Context): Agent { const events: Array<{ type: string; data?: Record }> = [{ type: 'turn/start' }] if (mode !== undefined) events.push({ type: 'bash/sandbox-mode', data: { mode } }) + const id = SessionId('sandbox-session') return { - id: 'sandbox-agent', + id, ...ctx === undefined ? {} : { ctx: ctx.plugin(() => {}).ctx }, session: { - header: { version: 0, id: 'sandbox-session', createdAt: 0 }, + id, + header: { version: 0, id, createdAt: 0 }, events, append: (type: string, data: Record) => { const event = { type, data } diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 937120eebe..950e815ebc 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -13,10 +13,10 @@ This backend owns the compaction policy: - **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold. - **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call. - **Framing** — the replacement user message marks established checkpoint context with `` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint. -- **Lifecycle** — `compactRegion()` requires its agent to own the exact target session and rejects mismatch before resolution or mutation; a valid call records its start, summary, replacement, and end. The serial `agent/pre-step` listener checks pressure before every step, outside an open step, so a tool-heavy turn remains compactable and the loop derives history once after mutation. +- **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. The serial `agent/pre-step` listener checks pressure before every step, outside an open step, so a tool-heavy turn remains compactable and the loop derives history once after mutation. - **Failure handling** — an unmatched `compact/start` is an inert crash marker because no replacement landed. Recoverable failure records an error end and leaves the surface unchanged. -`summarize()` is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the summary blocks together with the call envelope it used (`{ summary, provider, model, maxTokens? }`), which is logged on `compact/summary`. +The protected `summarize()` method is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the summary blocks together with the call envelope it used (`{ summary, provider, model, maxTokens? }`), which is logged on `compact/summary`. ## Config (`BasicCompactConfig`) diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 60452581c5..6912f0a0a4 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -21,7 +21,6 @@ import type { ResolvedConfig, } from './types.ts' -export { resolveConfig } from './config.ts' export type { BasicCompactConfig, ResolvedConfig, @@ -96,7 +95,7 @@ export class BasicCompactService extends CompactService { * @param signal - optional cancellation forwarded to the adapter. * @returns safe text summary blocks and exact auxiliary-call provenance. */ - async summarize( + protected async summarize( text: string, agent: Agent, signal?: AbortSignal, @@ -137,7 +136,7 @@ export class BasicCompactService extends CompactService { /* v8 ignore next -- paired with the defensive post-success branch above. */ break } - result = await this.compactRegion(agent.session, range.start, range.end, agent, signal) + result = await this.compactRegion(range.start, range.end, agent, signal) measurement = meter.measure(agent.session, requestHeader) if (measurement.totalTokens < threshold) return result } @@ -149,10 +148,8 @@ export class BasicCompactService extends CompactService { } /** - * Compact one inclusive positional surface range using the effective - * token meter for all retention and shrink pricing. Reject an agent that does - * not own the exact target before any mutation. - * @param session - session whose surface is mutated; must equal `agent.session`. + * Compact one inclusive positional range from the agent-owned surface using + * the effective token meter for all retention and shrink pricing. * @param start - inclusive first surface-node seq. * @param end - inclusive last surface-node seq. * @param agent - owner of the target session, used by the summarizer. @@ -160,15 +157,12 @@ export class BasicCompactService extends CompactService { * @returns the successful durable compaction result. */ override async compactRegion( - session: Session, start: number, end: number, agent: Agent, signal?: AbortSignal, ): Promise { - if (session !== agent.session) { - throw new Error('compactRegion: agent.session must be the exact target session') - } + const session = agent.session return compactSurfaceRegion({ meter: this.ctx.tokenMeter, summarize: (text, owner, abort) => this.summarize(text, owner, abort), diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 2d315293ed..caaac783b6 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -1,8 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import BasicCompactService, { resolveConfig } from '@deepseek-ai/dsh-compact-basic' +import BasicCompactService from '@deepseek-ai/dsh-compact-basic' import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' import { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts' +import { resolveConfig } from '@deepseek-ai/dsh-compact-basic/src/config.ts' import type { CompactionResult } from '@deepseek-ai/dsh-compact' import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message, StreamChunk } from '@deepseek-ai/dsh-llm' @@ -349,32 +350,11 @@ describe('pressure measurement and retention', () => { }) describe('compaction region transaction', () => { - it('rejects an agent that does not own the exact target session before mutation', async () => { - const compact = service() - const target = conversation(2) - const owner = conversation(1) - const targetEvents = [...target.events] - const ownerEvents = [...owner.events] - const nodes = target.surface.nodes - - await expect(compact.compactRegion( - target, - nodes[0]!, - nodes[1]!, - agent(owner), - )).rejects.toThrow('compactRegion: agent.session must be the exact target session') - - expect(target.events).toEqual(targetEvents) - expect(owner.events).toEqual(ownerEvents) - expect(compact.calls).toEqual([]) - }) - it('lands a framed, replayable checkpoint with exact pricing provenance', async () => { const compact = service() const session = conversation(3) const before = session.surface.nodes const result = await compact.compactRegion( - session, before[0]!, before[3]!, agent(session, MODEL), @@ -410,7 +390,6 @@ describe('compaction region transaction', () => { const session = conversation(2) const nodes = session.surface.nodes await expect(compact.compactRegion( - session, startOverride ?? nodes[0]!, endOverride ?? nodes[1]!, agent(session, MODEL), @@ -422,7 +401,6 @@ describe('compaction region transaction', () => { const plain = conversation(2) const nodes = plain.surface.nodes await expect(compact.compactRegion( - plain, nodes[2]!, nodes[1]!, agent(plain, MODEL), @@ -431,13 +409,11 @@ describe('compaction region transaction', () => { const tools = toolConversation() const toolNodes = tools.surface.nodes await expect(compact.compactRegion( - tools, toolNodes[2]!, toolNodes[4]!, agent(tools, MODEL), )).rejects.toThrow(/start seq .* not a balanced boundary/) await expect(compact.compactRegion( - tools, toolNodes[0]!, toolNodes[1]!, agent(tools, MODEL), @@ -450,7 +426,6 @@ describe('compaction region transaction', () => { closed.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) const nodes = closed.surface.nodes await expect(compact.compactRegion( - closed, nodes[0]!, nodes[1]!, agent(closed, MODEL), @@ -460,7 +435,6 @@ describe('compaction region transaction', () => { locked.append('compact/start', { turn: 2 }) const lockedNodes = locked.surface.nodes await expect(compact.compactRegion( - locked, lockedNodes[0]!, lockedNodes[1]!, agent(locked, MODEL), @@ -477,7 +451,6 @@ describe('compaction region transaction', () => { const node = session.surface.nodes[0]! await expect(compact.compactRegion( - session, node, node, agent(session, MODEL), @@ -497,7 +470,6 @@ describe('compaction region transaction', () => { const nodes = session.surface.nodes await expect(compact.compactRegion( - session, nodes[0]!, nodes[2]!, agent(session, MODEL), @@ -511,7 +483,6 @@ describe('compaction region transaction', () => { const before = session.surface.nodes await expect(compact.compactRegion( - session, before[0]!, before[2]!, agent(session, MODEL), @@ -527,7 +498,6 @@ describe('compaction region transaction', () => { const session = conversation(2) const nodes = session.surface.nodes await expect(compact.compactRegion( - session, nodes[0]!, nodes[2]!, agent(session, MODEL), @@ -548,7 +518,6 @@ describe('compaction region transaction', () => { const nodes = session.surface.nodes await expect(compact.compactRegion( - session, nodes[0]!, nodes[2]!, agent(session, MODEL), @@ -566,7 +535,6 @@ describe('compaction region transaction', () => { const nodes = session.surface.nodes await expect(compact.compactRegion( - session, nodes[0]!, nodes[2]!, agent(session, MODEL), @@ -579,7 +547,6 @@ describe('compaction region transaction', () => { const session = conversation(1) const nodes = session.surface.nodes await expect(compact.compactRegion( - session, nodes[0]!, nodes[1]!, agent(session), @@ -613,18 +580,28 @@ class ScriptedAdapter extends LlmAdapter { } } +class ExposedCompactService extends BasicCompactService { + runSummarize( + text: string, + owner: Agent, + signal?: AbortSignal, + ): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> { + return this.summarize(text, owner, signal) + } +} + async function summarizerHarness( blocks: readonly ContentBlock[], finish?: (StreamChunk & { type: 'finish' })['reason'], model = MODEL, config: BasicCompactConfig = { auto: false }, -): Promise<{ ctx: Context; adapter: ScriptedAdapter; compact: BasicCompactService }> { +): Promise<{ ctx: Context; adapter: ScriptedAdapter; compact: ExposedCompactService }> { const ctx = new Context() await ctx.plugin(LlmService) void new TokenMeterService(ctx, { contextWindow: 1_000 }) const adapter = new ScriptedAdapter(blocks, finish) ctx.llm.registerAdapter([model], adapter) - const compact = new BasicCompactService(ctx, config) + const compact = new ExposedCompactService(ctx, config) return { ctx, adapter, compact } } @@ -641,7 +618,7 @@ describe('default one-shot summarizer', () => { maxTokens: 321, }) const session = conversation(1) - const output = await compact.summarize('transcript', agent(session, 'fallback'), SIGNAL) + const output = await compact.runSummarize('transcript', agent(session, 'fallback'), SIGNAL) expect(output).toEqual({ summary: [{ type: 'text', text: 'public summary' }], @@ -666,7 +643,7 @@ describe('default one-shot summarizer', () => { header: { config: { provider: 'routed', model: 'routed' } }, reason: 'initial', }) - const output = await compact.summarize('history', agent(session, 'fallback')) + const output = await compact.runSummarize('history', agent(session, 'fallback')) expect(output.provider).toBe('routed') expect(output.model).toBe('routed') expect(adapter.lastOptions?.provider).toBe('routed') @@ -677,8 +654,8 @@ describe('default one-shot summarizer', () => { const ctx = new Context() await ctx.plugin(LlmService) void new TokenMeterService(ctx) - const compact = new BasicCompactService(ctx, { auto: false }) - await expect(compact.summarize('history', agent(new Session(SessionId('model-less'))))) + const compact = new ExposedCompactService(ctx, { auto: false }) + await expect(compact.runSummarize('history', agent(new Session(SessionId('model-less'))))) .rejects.toThrow(/no provider\/model available for summarization/) }) @@ -693,7 +670,7 @@ describe('default one-shot summarizer', () => { const { compact } = await summarizerHarness([], finish) let thrown: unknown try { - await compact.summarize('history', agent(conversation(1), MODEL)) + await compact.runSummarize('history', agent(conversation(1), MODEL)) } catch (error: unknown) { thrown = error } @@ -705,7 +682,7 @@ describe('default one-shot summarizer', () => { it('rejects empty or reasoning-only successful output', async () => { const { compact } = await summarizerHarness([{ type: 'reasoning', text: 'private' }]) - await expect(compact.summarize('history', agent(conversation(1), MODEL))) + await expect(compact.runSummarize('history', agent(conversation(1), MODEL))) .rejects.toThrow(/no text summary content/) }) }) 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 d163d84804..c3f2bb10d9 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -4,13 +4,13 @@ import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-a import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import { defineTool } from '@deepseek-ai/dsh-tools' -import { AgentId } from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as Invariants from '@deepseek-ai/dsh-invariants' import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' import TokenMeterService from '@deepseek-ai/dsh-token-meter' -import type { SurfaceEvent } from '@deepseek-ai/dsh-session' +import { SessionId, type SurfaceEvent } from '@deepseek-ai/dsh-session' /** * CBR-001 regression through the real loop. A replacement checkpoint has a high @@ -83,7 +83,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr return { ctx, compact } } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -98,7 +98,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () it('the head checkpoint the loop lands is a balanced cut on both sides', async () => { const { ctx } = await harness(8) try { - const agent = ctx.agentLoop.create(AgentId('repro'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('repro'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'do a long multi-step task' }]) await waitForIdle(ctx, agent) diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index ec14947090..80ddc00e1a 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -19,7 +19,9 @@ Both methods are **abstract** — the backend owns trigger policy, retention, ev | Member | Semantics | |---|---| | `compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` | Estimate the surface-derived history size; if over the backend's threshold, compact an older range via `compactRegion`, keeping recent context intact. Returns the `CompactionResult`, or `null` if nothing needed compacting. All parameters required — the loop's `agent/pre-step` checkpoint supplies the agent, assembled `fullSystemPrompt`, composed `sessionPrefix` (request-only messages every request carries but the derived history omits — the pressure estimate must count them), and turn `signal`. A backend's summarization request is a direct `ctx.llm.stream()` call (not a loop step), so per-call interception happens at `llm/stream`. | -| `compactRegion(session, start, end, agent, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) into a single replacement node. The agent must own the exact target (`session === agent.session`); a backend rejects mismatch before model resolution, lock acquisition, summarization, or log mutation. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. | +| `compactRegion(start, end, agent, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) from `agent.session` into a single replacement node. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. | + +`CompactionResult` keeps the raw summary and bookkeeping-event seqs available to callers alongside the shadowed range and token accounting; its drift-checked shape lives in the [compaction data-structure reference](../../../docs/core-data-structures/compaction.md#compactionresult). `compactIfNeeded` takes a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The turn that the `compact/*` events belong to is recoverable from the owned session's log (the currently-open turn), so the backend stamps it from the log rather than trusting a caller-supplied value. diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index 7361d38ef3..8ec4f59d96 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -67,23 +67,19 @@ export abstract class CompactService extends Service { * `start` and `end` name an inclusive span by surface position, not numeric seq * order; replacements can make visible seqs non-monotonic. Both edges must be * balanced so assistant tool calls remain paired with their results. A model- - * backed implementation forwards cancellation. The agent must own the exact - * target session object; implementations reject an ownership mismatch before - * model resolution, lock acquisition, summarization, or log mutation, and - * reject active, missing, reversed, or unbalanced ranges. + * backed implementation forwards cancellation and rejects active, missing, + * reversed, or unbalanced ranges. The target session is `agent.session`. * Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter} * for the edge checks. * - * @param session - session to mutate; must be identical to `agent.session`. * @param start - first surface seq, inclusive. * @param end - last surface seq, inclusive. - * @param agent - owner of the target session and summarizer context. + * @param agent - context whose session is mutated and whose routing options guide summarization. * @param signal - optional cancellation; model-backed implementations must forward it. - * @throws when the agent does not own `session`, compaction is active, or the range is missing, reversed, or unbalanced. - * @returns the replaced range and summary. + * @throws when compaction is active or the range is missing, reversed, or unbalanced. + * @returns the appended event seqs, summary, replaced range, and token accounting. */ abstract compactRegion( - session: Session, start: number, end: number, agent: CompactAgentContext, diff --git a/packages/compact/compact/tests/compact.spec.ts b/packages/compact/compact/tests/compact.spec.ts index f272b1fe85..d99510baa6 100644 --- a/packages/compact/compact/tests/compact.spec.ts +++ b/packages/compact/compact/tests/compact.spec.ts @@ -27,17 +27,18 @@ class StubCompactService extends CompactService { } override async compactRegion( - session: Session, start: number, end: number, - _agent: CompactAgentContext, + agent: CompactAgentContext, signal?: AbortSignal, ): Promise { this.lastSignal = signal + const session = agent.session + const summary = [{ type: 'text' as const, text: 'stub' }] // Minimal stub honoring the lock + log-only event contract. const startEvent = session.append('compact/start', { turn: 0 }) const summaryEvent = session.append('compact/summary', { - summary: [{ type: 'text', text: 'stub' }], + summary, shadowedRange: { start, end }, shadowedSeqs: [], shadowedTokenCount: 0, @@ -49,7 +50,7 @@ class StubCompactService extends CompactService { startSeq: startEvent.seq, summarySeq: summaryEvent.seq, endSeq: endEvent.seq, - summary: [{ type: 'text', text: 'stub' }], + summary, shadowedRange: { start, end }, shadowedSeqs: [], shadowedTokenCount: 0, @@ -89,7 +90,7 @@ describe('CompactService seam', () => { const svc = new StubCompactService(ctx) const session = new Session(SessionId('s')) - const result = await svc.compactRegion(session, 0, 0, stubAgent(session, 'm')) + const result = await svc.compactRegion(0, 0, stubAgent(session, 'm')) const startEvent = session.events.find(e => e.type === 'compact/start') expect(startEvent).toBeDefined() @@ -97,8 +98,12 @@ describe('CompactService seam', () => { // verify the runtime value is absent. const raw = startEvent as unknown as { surfaceOp?: unknown } expect(raw.surfaceOp).toBeUndefined() + expect(result.summary).toEqual([{ type: 'text', text: 'stub' }]) expect(result.summarySeq).toBeGreaterThan(result.startSeq) expect(result.endSeq).toBeGreaterThan(result.summarySeq) + expect(result.shadowedRange).toEqual({ start: 0, end: 0 }) + expect(session.events.filter(e => e.type.startsWith('compact/')).map(e => e.type)) + .toEqual(['compact/start', 'compact/summary', 'compact/end']) }) it('threads the cancellation signal through to the backend', async () => { @@ -107,7 +112,7 @@ describe('CompactService seam', () => { const session = new Session(SessionId('s')) const controller = new AbortController() - await svc.compactRegion(session, 0, 0, stubAgent(session, 'm'), controller.signal) + await svc.compactRegion(0, 0, stubAgent(session, 'm'), controller.signal) expect(svc.lastSignal).toBe(controller.signal) await svc.compactIfNeeded(stubAgent(session), '', [], controller.signal) diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 2796e8230c..f2c6afe586 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -4,7 +4,7 @@ import Loader from '@cordisjs/plugin-loader' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { defineTool } from '@deepseek-ai/dsh-tools' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -38,7 +38,7 @@ async function mount(config: Config = {}) { function sessionAgent(session: Session, id = 'agent'): Agent { return { - id: AgentId(id), + id: SessionId(id), options: {}, session, status: 'running', @@ -370,7 +370,7 @@ describe('real agent-loop request history', () => { if (mode === 'throws') throw new Error('later pre-step failure') subject.cancel('later pre-step cancellation') }) - const agent = ctx.agentLoop.create(AgentId(`late-${mode}`), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId(`late-${mode}`), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'start' }]) await agent.whenIdle() @@ -396,7 +396,7 @@ describe('real agent-loop request history', () => { return [{ type: 'text' as const, text: 'advanced' }] }, })) - const agent = ctx.agentLoop.create(AgentId('loop'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('loop'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'start' }]) await agent.whenIdle() diff --git a/packages/context/workspace-context/tests/workspace-context.e2e.ts b/packages/context/workspace-context/tests/workspace-context.e2e.ts index 982bf87ffa..3710f77547 100644 --- a/packages/context/workspace-context/tests/workspace-context.e2e.ts +++ b/packages/context/workspace-context/tests/workspace-context.e2e.ts @@ -7,7 +7,7 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -48,7 +48,6 @@ async function harness(): Promise<{ ctx: Context; agent: Agent }> { await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LlmDeepSeek, { models: [{ id: 'deepseek-v4-flash' }] }) const handle = await ctx.agents.create({ - agentId: AgentId('workspace-context-e2e'), sessionId: SessionId('workspace-context-e2e-session'), meta: { cwd: workdir }, agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 11760e0348..f161ac0bc9 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -7,7 +7,7 @@ import Loader from '@cordisjs/plugin-loader' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import LlmService, { CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session' -import AgentRegistry, { AgentId, type Agent, type HookContext } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent, type HookContext } from '@deepseek-ai/dsh-agent' import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' @@ -164,7 +164,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { const session = new Session(id, seed, cwd === undefined ? undefined : { version: SESSION_FORMAT_VERSION, id, createdAt: 0, cwd }) return { ctx: new Context(), - id: AgentId('a1'), + id: SessionId('a1'), options: {}, session, status: 'idle', @@ -1593,7 +1593,7 @@ describe('dynamic nested workspace context injection', () => { await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('workspace-context-abort'), { provider: 'mock', model: 'mock' }, { cwd: root }) + const agent = ctx.agentLoop.create(SessionId('workspace-context-abort'), { provider: 'mock', model: 'mock' }, { cwd: root }) ctx.tools.register(defineTool({ name: 'abort_step', description: 'Abort the current test step.', diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 0683dc467b..4ff01c81ae 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -63,9 +63,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'agentLoop', - summary: 'Concrete ReactLoopAgent factory and driver service.', + summary: 'Concrete agent factory and driver service.', methods: [ - 'create(id: AgentId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent', + 'create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): Agent', 'async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise', 'async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise', ], @@ -78,10 +78,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 'async create(options: CreateAgentOptions): Promise', 'async resume(options: ResumeAgentOptions): Promise', 'register(agent: Agent): () => void', - 'enter(agent: Agent): () => void', + 'enter(agent: Agent, owner: Agent | undefined): () => void', 'announce(agent: Agent): void', - 'get(id: AgentId): Agent | undefined', + 'get(id: SessionId): Agent | undefined', + 'isOwnedBy(id: SessionId, owner: Agent): boolean', 'list(): Agent[]', + 'roots(): Agent[]', ], }, { @@ -121,7 +123,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'Abstract compaction service.', methods: [ 'abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal, ): Promise', - 'abstract compactRegion( session: Session, start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise', + 'abstract compactRegion( start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise', ], }, { @@ -302,6 +304,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ /** Every harness event, sorted by name. */ export const EVENT_API: readonly EventApiEntry[] = [ + { + name: 'agent-loop/config-start-failed', + mode: 'emit', + signature: '\'agent-loop/config-start-failed\'(sessionId: SessionId, error: unknown): void', + summary: 'A declarative agent entry failed before it could publish a live agent.', + }, { name: 'agent/created', mode: 'emit', @@ -542,7 +550,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ export const TYPE_API: readonly TypeApiEntry[] = [ { name: 'Agent', - declaration: 'export interface Agent {\n readonly id: AgentId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n}', + declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n}', }, { name: 'AgentExecution', @@ -556,10 +564,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentHandle', declaration: 'export interface AgentHandle {\n agent: Agent;\n dispose(): Promise;\n}', }, - { - name: 'AgentId', - declaration: 'export type AgentId = Branded<\'AgentId\'>;', - }, { name: 'AgentOptions', declaration: 'export interface AgentOptions {\n provider?: string;\n model?: string;\n}', @@ -714,7 +718,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CreateAgentOptions', - declaration: 'export interface CreateAgentOptions {\n readonly agentId: AgentId;\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', + declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', }, { name: 'CreateSessionOptions', @@ -866,7 +870,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ResumeAgentOptions', - declaration: 'export interface ResumeAgentOptions {\n readonly agentId: AgentId;\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', + declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', }, { name: 'SandboxEnforcement', @@ -1038,7 +1042,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentRun', - declaration: 'export interface SubagentRun {\n readonly id: AgentId;\n readonly result: Promise;\n dispose(): Promise;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): Promise;\n}', + declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly localAgent: Agent | undefined;\n readonly result: Promise;\n dispose(): Promise;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): Promise;\n}', }, { name: 'SubagentStartRequest', diff --git a/packages/cordis/tool-cordis/tests/integration.spec.ts b/packages/cordis/tool-cordis/tests/integration.spec.ts index 9823e119b5..d68f6be349 100644 --- a/packages/cordis/tool-cordis/tests/integration.spec.ts +++ b/packages/cordis/tool-cordis/tests/integration.spec.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as ToolCordis from '../src/index.ts' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -24,7 +25,7 @@ async function harness(adapter: MockAdapter): Promise { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -44,7 +45,7 @@ describe('cordis tools through the agent loop', () => { textResponse('Done.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('it-cordis'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('it-cordis'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'give yourself reverse_text, use it, clean up' }]) await waitForIdle(ctx, agent) diff --git a/packages/cordis/tool-cordis/tests/mount.spec.ts b/packages/cordis/tool-cordis/tests/mount.spec.ts index 09011c77de..2ddee8dbca 100644 --- a/packages/cordis/tool-cordis/tests/mount.spec.ts +++ b/packages/cordis/tool-cordis/tests/mount.spec.ts @@ -175,9 +175,13 @@ describe('cordis_mount', () => { // The registered schema is canonical JSON Schema derived from the DSL: // the required array survived, integer became number, extra is optional. const schema = ctx.tools.schemas().find(s => s.name === 'json_schema_tool')! - const parameters = schema.parameters as { properties: Record; required?: string[] } + const parameters = schema.parameters as { + properties: Record + required?: string[] + } expect(parameters.required).toEqual(['text']) expect(parameters.properties.count!.type).toBe('number') + expect(parameters.properties.count!.default).toBe(1) expect(parameters.properties.mode!.enum).toEqual(['fast', 'slow']) // Arg validation enforces the normalized spec: text required, extra not. expect((await call(ctx, 'json_schema_tool', { count: 2 })).isError).toBe(true) diff --git a/packages/core/README.md b/packages/core/README.md index cda06b854a..6736e29af2 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -10,7 +10,7 @@ The session log, system-prompt assembly, tool registry, agent vocabulary, and co | `tools/` | Scoped tool registry + pre-policy, guards, around-dispatch, post-policy, and final-result observation | `ctx.tools` | | `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` | | `agent-execution/` | Process-local ambient Agent identity for asynchronous driver work | `ctx.agentExecution` | -| `agent-loop/` | The concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` | +| `agent-loop/` | Concrete plugin implementing the public `Agent` contract and owning the loop driver | `ctx.agentLoop` | `scope/` is the one non-service package here: a dependency-free library (`createScope`/`scopeOf`/`scopeTarget`) the registries and the loop build per-agent scoping on — it sits below `session/` and `system-prompt/` in the module graph precisely so they can consume it without a cycle. diff --git a/packages/core/agent-execution/tests/agent-execution.spec.ts b/packages/core/agent-execution/tests/agent-execution.spec.ts index e03010c569..b18d19e63d 100644 --- a/packages/core/agent-execution/tests/agent-execution.spec.ts +++ b/packages/core/agent-execution/tests/agent-execution.spec.ts @@ -1,12 +1,13 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { runInNewContext } from 'node:vm' -import { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import type { AgentExecution, AgentExecutionService } from '@deepseek-ai/dsh-agent-execution' +import { SessionId } from '@deepseek-ai/dsh-session' function execution(id: string): AgentExecution { - return { agent: { id: AgentId(id) } as Agent } + return { agent: { id: SessionId(id) } as Agent } } async function harness(): Promise<{ diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index eda0d70eae..2acda4ab52 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -1,6 +1,6 @@ # dsh-agent-loop -Concrete `ReactLoopAgent` implementation and loop driver. +THE concrete agent plugin and loop driver. Its package-internal implementation satisfies the `Agent` interface and drives the session/turn/step lifecycle. This is the only package in the harness that contains concrete loop logic. Everything else is an abstract service or a plugin against extension seams — new behavior goes into plugins, not here. @@ -8,16 +8,18 @@ This is the only package in the harness that contains concrete loop logic. Every ### Public API -Creation and resume use one caller-owned transaction: compose while unpublished, enter both registries, announce lifecycle edges, then start the driver. Failure rolls back private resources; caller, handle, and provider teardown share one quiescence boundary. The interface contract and ownership order live in [`dsh-agent`](../agent/README.md) and the [agent-scope runtime RFC](../../../docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md). +Creation and resume are one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; and only then start the driver. Setup receives the full scoped `Context` as trusted same-process composition code and must not drive the unpublished agent. Ordinary typed identity and option inputs are borrowed under their readonly contract, while seed events and session metadata are validated and snapshotted because they cross the durable session boundary. An optional `AbortSignal` cancels only load/setup/publication and is detached before the returned handle becomes visible. -Caller-chosen ids arbitrate only at final registry entry, so concurrent contenders may prepare but every loser rolls back. Entry-bound detach capabilities cannot remove a later same-id replacement. Teardown stops and drains—including idle-injection flushes—before detaching agent, session, and scope; ids become reusable at detach. +The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly, while the factory keeps its own dependency context for `sessions`/`llm`/`tools`/`systemPrompt`; this lets a caller inject only `agents` without shrinking the new agent's service surface. Caller unload, handle disposal, or provider unload converge on one memoized quiescence boundary. Provider shutdown waits both resource teardown and the public create/resume wrapper that observed deactivation, so no continuation can publish after dependencies disappear. -- `ctx.agentLoop.create(id, options?, meta?)` synchronously creates a caller-fiber-owned agent with a fresh generated session id and optional cwd. Each call starts a new session rather than applying resume-or-create policy. +Each agent and its session share one caller-chosen `SessionId`, assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain (including outstanding idle-injection flushes) → detach agent → detach session → unwind scope; the id becomes reusable at detach even if private scope cleanup is still finishing. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, per-step assembly goes through `assembleContextFor(agent)`, and turn-end durability checkpoints go through `ctx.sessions.flush(session)`. + +- `ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): Agent` — synchronous no-setup create under the exact shared agent/session id, disposed with the calling fiber. Declarative config treats `agents[].id` as a stable label and normally mints `${label}-session-` before calling this boundary. An app may instead supply a stable exact `sessionId`: first use creates it, while a remount with persistence already present resumes its materialized history. `resumeSessionId` requires and loads an existing persisted id and is mutually exclusive with `sessionId`. This keeps default fresh restarts collision-free without retaining a second live routing identity. `AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface): -- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions?, setup?, signal? })` validates and snapshots durable seed and metadata, awaits optional composition while unpublished, creates on the supplied session id, and returns an owned [`AgentHandle`](../agent/README.md). Its signal applies only until publication. -- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions?, setup?, signal? })` loads through optional [session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md), continues stored history and turn numbering under the resumed session id, and follows the same unpublished setup and creation-only cancellation boundary. It rejects when no persistence backend is mounted. +- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise` — programmatic create under the caller-supplied shared id. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown. +- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), register the agent under that same id, reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. Turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`. The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle). For a programmatic agent, the handle holder is the only consumer-facing teardown capability; AgentLoop provider unload is the independent structural teardown edge, not another handle exposed to application code. @@ -42,11 +44,9 @@ interface Config { Configured agents start automatically. A model call requires both `provider` and `model`; `agent/request` may supply a missing pair before dispatch. `maxParallelToolCalls` bounds every agent's rolling pool for parallel-safe calls and defaults to `10`. `cwd` applies only to fresh sessions, while `resumeSessionId` retains persisted metadata. Configured agents use the deployment persona, and programmatic setup can shadow it per agent. This plugin supplies the per-agent `provider`, `model`, and `cwd` prompt variables; harness identity and deployment persona belong to `dsh-system-prompt`. -### Exported concrete class +### Internal concrete driver -- `ReactLoopAgent` — the concrete `Agent` implementation. Its inbox is a JavaScript native-private field, and one prepared session can be claimed by only one concrete driver. Everything observable happens through session events and the `agent/*` event taxonomy. - -`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. `ReactLoopAgent.send()`, running `steer()`, and open-turn `inject()` materialize content plus resolved source once as detached, deeply frozen lossless JSON; malformed data throws before enqueue or append. An injection that arrives while the current step executes assistant tool calls stays in a FIFO until the batch settles; successful batches place it after the complete result batch, and interrupted batches drain it before the turn closes. +The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. The concrete `send()`, running `steer()`, and open-turn `inject()` materialize content plus resolved source once as detached, deeply frozen lossless JSON; malformed data throws before enqueue or append. An injection that arrives while the current step executes assistant tool calls stays in a FIFO until the batch settles; successful batches place it after the complete result batch, and interrupted batches drain it before the turn closes. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy. ### Loop lifecycle (`loop.ts`) @@ -87,6 +87,6 @@ Everything that goes beyond "call the model, run the tools, repeat" belongs to p ## Known Limitations and Deferred Work - **Classification is unary** — calls whose safety depends on comparing siblings or resources must remain exclusive ([rationale](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md)). -- **No resume-or-create policy on the config path** — config-driven `create()` starts a fresh `${id}-session-` every run (`TODO(demo)`), and a config `resumeSessionId` whose resume fails logs a warning and creates no agent. +- **Config labels are fresh by default** — omitting `sessionId` creates a fresh `${id}-session-` on every startup; exact resume-or-create behavior requires an explicit stable `sessionId`, while `resumeSessionId` requires existing persisted history. - **Config agents have no per-agent persona field or setup hook** — they use the deployment persona; scoped persona/tool composition is available only through the programmatic `ctx.agents.create()` / `resume()` factory options. - **No built-in turn budget** — the default continuation is `continue` whenever a step had tool calls or steering; bounding a runaway turn requires an `agent/turn-continuation` force-stop plugin. diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 9b852f0eb0..67c9160332 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -8,11 +8,11 @@ import type { Context } from 'cordis' import { agentEvents } from '@deepseek-ai/dsh-agent' -import type { AgentId, AgentOptions, AgentStatus, HookContext, InjectOptions, SendOptions } from '@deepseek-ai/dsh-agent' +import type { AgentOptions, AgentStatus, HookContext, InjectOptions, SendOptions } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { deepFreeze } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import { snapshotJsonValue, type Session } from '@deepseek-ai/dsh-session' +import { snapshotJsonValue, type Session, type SessionId } from '@deepseek-ai/dsh-session' import { Inbox, type InboxMessage } from './inbox.ts' import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts' @@ -58,7 +58,11 @@ export interface PreparedReactLoopAgent { * @returns the agent and closures bound only to that exact instance. */ export function prepareReactLoopAgent( - ctx: Context, id: AgentId, options: AgentOptions, session: Session, maxParallelToolCalls: number, + ctx: Context, + id: SessionId, + options: AgentOptions, + session: Session, + maxParallelToolCalls: number, ): PreparedReactLoopAgent { if (claimedDriverSessions.has(session)) { throw new Error(`session "${session.id}" already has a concrete agent driver`) @@ -159,7 +163,7 @@ export class ReactLoopAgent implements Agent { constructor( private loopCtx: Context, - public readonly id: AgentId, + public readonly id: SessionId, public readonly options: AgentOptions, public readonly session: Session, maxParallelToolCalls: number, diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 39efffb3a1..6a0d714ef3 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -13,9 +13,9 @@ import type { Scope } from '@deepseek-ai/dsh-scope' import { agentEvents } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-execution' import type { + Agent, AgentFactory, AgentHandle, - AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions, @@ -35,8 +35,6 @@ import { import type { PreparedReactLoopAgent } from './agent.ts' import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts' -export { ReactLoopAgent } from './agent.ts' - /** Fiber states that cannot own or serve a new lifecycle. */ const INACTIVE_STATES: ReadonlySet = new Set([ FiberState.UNLOADING, @@ -44,10 +42,21 @@ const INACTIVE_STATES: ReadonlySet = new Set([ FiberState.FAILED, ]) +/** Render an arbitrary thrown value without letting coercion escape containment. */ +function renderThrown(value: unknown): string { + try { + return String(value) + } catch { + return '' + } +} + /** Factory-level ownership of every preparing or live transaction. */ class FactoryOwnership { private accepting = true + private readonly inactive = Promise.withResolvers() private transactions = new Set() + private startupTasks = new Set>() constructor(private readonly fiber: Context['fiber']) {} @@ -60,17 +69,31 @@ class FactoryOwnership { return () => { this.transactions.delete(transaction) } } + /** Join config startup work that begins before an agent transaction exists. */ + trackStartup(task: Promise): void { + this.startupTasks.add(task) + const forget = () => { this.startupTasks.delete(task) } + void task.then(forget, forget) + } + + /** Resolve `task`, or stop waiting when factory teardown begins. */ + async waitWhileActive(task: Promise): Promise { + await Promise.race([task, this.inactive.promise]) + } + async dispose(): Promise { this.accepting = false + this.inactive.resolve() const reason = new Error('agent loop is not active') - await Promise.all( - [...this.transactions].map(transaction => transaction.disposeForFactory(reason)), - ) + await Promise.all([ + ...[...this.transactions].map(transaction => transaction.disposeForFactory(reason)), + ...this.startupTasks, + ]) } } /** Build the public cancellation error while preserving a caller-supplied cause. */ -function signalAbortError(id: AgentId, signal: AbortSignal): Error { +function signalAbortError(id: SessionId, signal: AbortSignal): Error { if (signal.reason instanceof Error) return signal.reason return new Error(`agent "${id}" creation aborted`, { cause: signal.reason }) } @@ -116,7 +139,7 @@ class AgentCreationTransaction { private readonly loopCtx: Context, private readonly ownerCtx: Context, private readonly ownership: FactoryOwnership, - readonly id: AgentId, + readonly id: SessionId, signal?: AbortSignal, ) { ownerCtx.fiber.assertActive() @@ -238,7 +261,7 @@ class AgentCreationTransaction { this.publishing = true try { this.detachSession = agent.ctx.sessions.enter(session) - this.detachAgent = this.loopCtx.agents.enter(agent) + this.detachAgent = this.loopCtx.agents.enter(agent, this.ownerAgent) agent.ctx.sessions.announce(session) this.assertActive() @@ -327,6 +350,18 @@ declare module 'cordis' { interface Context { agentLoop: AgentLoop } + interface Events { + /** + * A declarative agent entry failed before it could publish a live agent. + * Consumers that buffer work for the configured identity use this + * transient signal to reject that work instead of waiting forever. Normal + * factory teardown suppresses failures from the cancelled startup attempt. + * @param sessionId - exact shared agent/session identity that failed startup. + * @param error - persistence, setup, or publication failure. + * @mode emit + */ + 'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void + } } export { DEFAULT_MAX_PARALLEL_TOOL_CALLS } @@ -340,8 +375,10 @@ export interface Config { maxParallelToolCalls?: number /** Agents created or resumed at plugin startup. */ agents: (AgentOptions & { - /** Registry identity for the live agent. */ - id: AgentId + /** Stable config label used in logs and as the fresh combined-id prefix. */ + id: string + /** Optional stable identity; remounts resume its materialized history, while first use creates it fresh. */ + sessionId?: SessionId /** Optional workspace for a fresh session. */ cwd?: string /** Persisted session to resume instead of creating a fresh session. */ @@ -349,7 +386,25 @@ export interface Config { })[] } -/** Concrete ReactLoopAgent factory and driver service. */ +/** Reject self-contained identity conflicts before any configured agent starts. */ +function validateConfiguredAgents(agents: Config['agents']): void { + const exactIdentities = new Map() + for (const { id, sessionId, resumeSessionId } of agents) { + const hasResumeId = resumeSessionId !== undefined && resumeSessionId !== '' + if (sessionId !== undefined && hasResumeId) { + throw new Error(`agent "${id}": sessionId and resumeSessionId are mutually exclusive`) + } + const exactIdentity = hasResumeId ? resumeSessionId : sessionId + if (exactIdentity === undefined) continue + const firstId = exactIdentities.get(exactIdentity) + if (firstId !== undefined) { + throw new Error(`agents "${firstId}" and "${id}" use duplicate exact session identity "${exactIdentity}"`) + } + exactIdentities.set(exactIdentity, id) + } +} + +/** Concrete agent factory and driver service. */ export class AgentLoop extends Service implements AgentFactory { static inject = ['agents', 'agentExecution', 'sessions', 'llm', 'tools', 'systemPrompt'] @@ -358,6 +413,7 @@ export class AgentLoop extends Service implements AgentFactory { maxParallelToolCalls: z.number().step(1).min(1).default(DEFAULT_MAX_PARALLEL_TOOL_CALLS), agents: z.array(z.object({ id: z.string().required(), + sessionId: z.string().min(1), provider: z.string(), model: z.string(), cwd: z.string(), @@ -373,6 +429,7 @@ export class AgentLoop extends Service implements AgentFactory { constructor(ctx: Context, public config: Config) { super(ctx, 'agentLoop') + validateConfiguredAgents(config.agents) this.maxParallelToolCalls = resolveMaxParallelToolCalls(config.maxParallelToolCalls) this.ownership = new FactoryOwnership(ctx.fiber) this.runtime = { ctx } @@ -382,19 +439,28 @@ export class AgentLoop extends Service implements AgentFactory { ctx.systemPrompt.variable('model', context => context.agent?.options.model) ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd) - for (const { id, cwd, resumeSessionId, ...options } of config.agents) { + for (const { id, sessionId, cwd, resumeSessionId, ...options } of config.agents) { + const meta = cwd === undefined ? {} : { cwd } if (resumeSessionId === undefined || resumeSessionId === '') { - this.create(id, options, cwd === undefined ? {} : { cwd }) + const configuredId = sessionId ?? SessionId(`${id}-session-${randomUUID()}`) + const persistence = sessionId === undefined ? undefined : ctx.get('sessionPersistence') + if (persistence === undefined) { + this.create(configuredId, options, meta) + } else { + const startup = this.restoreOrCreateConfigured(ctx, persistence, configuredId, options, meta).catch((error: unknown) => { + this.reportConfiguredStartupFailure(id, 'restore', configuredId, error) + }) + this.ownership.trackStartup(startup) + } continue } ctx.effect(() => { const fiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => { void this.resumeWith(ctx, childCtx.sessionPersistence, { - agentId: id, resumeSessionId, agentOptions: options, }).catch((error: unknown) => { - ctx.logger.warn(`agent "${id}": config-driven resume of "${resumeSessionId}" failed: ${String(error)}`) + this.reportConfiguredStartupFailure(id, 'resume', resumeSessionId, error) }) }) return fiber.dispose @@ -402,20 +468,83 @@ export class AgentLoop extends Service implements AgentFactory { } } + /** Report a contained declarative-start failure to identity-bound consumers. */ + private reportConfiguredStartupFailure( + configId: string, + action: 'restore' | 'resume', + sessionId: SessionId, + error: unknown, + ): void { + if (!this.ownership.isActive()) return + this.ctx.logger.warn(`agent "${configId}": config-driven ${action} of "${sessionId}" failed: ${renderThrown(error)}`) + const args: unknown[] = ['agent-loop/config-start-failed', sessionId, error] + for (const callback of this.ctx.events.dispatch('emit', args)) { + try { + const returned: unknown = callback(...args) + void Promise.resolve(returned).catch((listenerError: unknown) => { + this.ctx.logger.warn(`agent "${configId}": config-start-failed listener rejected: ${renderThrown(listenerError)}`) + }) + } catch (listenerError: unknown) { + this.ctx.logger.warn(`agent "${configId}": config-start-failed listener threw: ${renderThrown(listenerError)}`) + } + } + } + + /** Restore a materialized exact config identity on remount, or create it on first use. */ + private async restoreOrCreateConfigured( + ownerCtx: Context, + persistence: SessionPersistence, + sessionId: SessionId, + agentOptions: AgentOptions, + meta: Pick, + ): Promise { + await this.waitForDrainingConfiguredIdentity(ownerCtx, sessionId) + if (!this.ownership.isActive()) return + const exists = (await persistence.list()).some(header => header.id === sessionId) + if (!this.ownership.isActive()) return + if (exists) { + await this.resumeWith(ownerCtx, persistence, { resumeSessionId: sessionId, agentOptions }) + return + } + this.create(sessionId, agentOptions, meta) + } + + /** Wait for an already-disposed same-id lifecycle to finish registry teardown. */ + private async waitForDrainingConfiguredIdentity(ownerCtx: Context, sessionId: SessionId): Promise { + const current = ownerCtx.agents.get(sessionId) + if (current?.status !== 'disposed') return + + const released = Promise.withResolvers() + const checkReleased = (): void => { + if (ownerCtx.agents.get(sessionId) === undefined && ownerCtx.sessions.get(sessionId) === undefined) { + released.resolve() + } + } + const disposeAgentListener = ownerCtx.on('agent/disposed', checkReleased) + const disposeSessionListener = ownerCtx.on('session/disposed', checkReleased) + try { + checkReleased() + await this.ownership.waitWhileActive(released.promise) + } finally { + disposeAgentListener() + disposeSessionListener() + } + } + /** - * Create an agent on a fresh per-run session, owned by the accessing fiber. - * Constructor-driven config calls use the loop fiber itself. - * @param id - agent registry id. + * Create an agent and session under one caller-supplied identity, owned by + * the accessing fiber. Constructor-driven config calls mint a fresh combined + * id before entering this boundary. + * @param id - shared agent/session identity. * @param options - concrete loop options. * @param meta - optional fresh-session workspace metadata. * @returns the published running agent. */ - create(id: AgentId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent { + create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): Agent { const loopCtx = this.runtime.ctx const transaction = new AgentCreationTransaction(loopCtx, this.ctx, this.ownership, id) try { - const sessionId = SessionId(`${id}-session-${randomUUID()}`) - const session = loopCtx.sessions.prepare(sessionId, { meta }) + const session = loopCtx.sessions.prepare(id, { meta }) const agent = transaction.prepare(options, session, this.maxParallelToolCalls) transaction.publish('startup') return agent @@ -439,7 +568,7 @@ export class AgentLoop extends Service implements AgentFactory { this.runtime.ctx, ownerCtx, this.ownership, - options.agentId, + options.sessionId, options.signal, ) try { @@ -484,7 +613,7 @@ export class AgentLoop extends Service implements AgentFactory { this.runtime.ctx, ownerCtx, this.ownership, - options.agentId, + options.resumeSessionId, options.signal, ) try { diff --git a/packages/core/agent-loop/tests/agent-execution.spec.ts b/packages/core/agent-loop/tests/agent-execution.spec.ts index 79a3bf01fd..ee6f2d94c7 100644 --- a/packages/core/agent-loop/tests/agent-execution.spec.ts +++ b/packages/core/agent-loop/tests/agent-execution.spec.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' import { Context, FiberState, type Fiber } from 'cordis' -import AgentRegistry, { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import type { AgentExecutionService } from '@deepseek-ai/dsh-agent-execution' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -30,7 +30,7 @@ async function harness(adapter: LlmAdapter): Promise { return { ctx, providerFiber, loopFiber } } -function waitForIdle(ctx: Context, agent: ReactLoopAgent | Agent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -129,8 +129,8 @@ describe('AgentLoop execution context', () => { await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) - const a = ctx.agentLoop.create(AgentId('a'), { provider: 'mock', model: 'mock' }) - const b = ctx.agentLoop.create(AgentId('b'), { provider: 'mock', model: 'mock' }) + const a = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' }) + const b = ctx.agentLoop.create(SessionId('b'), { provider: 'mock', model: 'mock' }) const idleA = waitForIdle(ctx, a) const idleB = waitForIdle(ctx, b) send(a, 'a') @@ -167,7 +167,6 @@ describe('AgentLoop execution context', () => { execute: async (_args, exec) => { if (exec.agent === undefined) throw new Error('parent agent missing') const handle = await exec.agent.ctx.agents.create({ - agentId: AgentId('child'), sessionId: SessionId('child-session'), agentOptions: { provider: 'mock', model: 'mock' }, setup: (agentCtx) => { @@ -195,7 +194,6 @@ describe('AgentLoop execution context', () => { })) const parentHandle = await ctx.agents.create({ - agentId: AgentId('parent'), sessionId: SessionId('parent-session'), agentOptions: { provider: 'mock', model: 'mock' }, }) @@ -253,7 +251,6 @@ describe('AgentLoop execution context', () => { expect(directAmbient).toBeUndefined() const handle = await ctx.agents.create({ - agentId: AgentId('transport'), sessionId: SessionId('transport-session'), agentOptions: { provider: 'mock', model: 'mock' }, }) @@ -312,7 +309,6 @@ describe('AgentLoop execution context', () => { const oldService = ctx.agentExecution adapter.execution = oldService const oldHandle = await ctx.agents.create({ - agentId: AgentId('before-restart'), sessionId: SessionId('before-restart-session'), agentOptions: { provider: 'mock', model: 'mock' }, }) @@ -330,7 +326,6 @@ describe('AgentLoop execution context', () => { adapter.execution = ctx.agentExecution const newHandle = await ctx.agents.create({ - agentId: AgentId('after-restart'), sessionId: SessionId('after-restart-session'), agentOptions: { provider: 'mock', model: 'mock' }, }) @@ -357,7 +352,6 @@ describe('AgentLoop execution context', () => { const service = ctx.agentExecution adapter.execution = service const handle = await ctx.agents.create({ - agentId: AgentId('root-dispose'), sessionId: SessionId('root-dispose-session'), agentOptions: { provider: 'mock', model: 'mock' }, }) diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 7ae364b290..668190dd3d 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -1,16 +1,19 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' -import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS, ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' -import { bindReactLoopAgentContext, prepareReactLoopAgent } from '../src/agent.ts' +import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop' +import { bindReactLoopAgentContext, prepareReactLoopAgent, type ReactLoopAgent } from '../src/agent.ts' import { MockAdapter, textResponse } from './mock-adapter.ts' +function driverDone(agent: Agent): Promise { + return (agent as Agent & { done: Promise }).done +} + async function harness(adapter: MockAdapter) { const ctx = new Context() await ctx.plugin(LlmService) @@ -24,7 +27,7 @@ async function harness(adapter: MockAdapter) { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -35,7 +38,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } -function waitForStatus(ctx: Context, agent: ReactLoopAgent, expected: ReactLoopAgent['status']): Promise { +function waitForStatus(ctx: Context, agent: Agent, expected: Agent['status']): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === expected) { @@ -46,23 +49,23 @@ function waitForStatus(ctx: Context, agent: ReactLoopAgent, expected: ReactLoopA }) } -function send(agent: ReactLoopAgent, text: string) { +function send(agent: Agent, text: string) { agent.send([{ type: 'text', text }]) } -describe('ReactLoopAgent', () => { +describe('Agent', () => { it('rejects access before context binding and a second driver for one session', async () => { const ctx = new Context() await ctx.plugin(AgentExecutionProvider) await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('exclusive-driver')) const prepared = prepareReactLoopAgent( - ctx, AgentId('first-driver'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS, + ctx, SessionId('first-driver'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS, ) expect(() => prepared.agent.ctx).toThrow('context is not bound') expect(() => prepareReactLoopAgent( - ctx, AgentId('second-driver'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS, + ctx, SessionId('second-driver'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS, )) .toThrow('already has a concrete agent driver') @@ -73,12 +76,12 @@ describe('ReactLoopAgent', () => { it('borrows caller options and binds its scoped context exactly once', async () => { const ctx = await harness(new MockAdapter([textResponse('unused')])) const options = { provider: 'mock', model: 'mock' } - const agent = ctx.agentLoop.create(AgentId('owned-bindings'), options) + const agent = ctx.agentLoop.create(SessionId('owned-bindings'), options) expect(agent.options).toBe(options) expect(agent.id).toBe('owned-bindings') - expect(agent.session.id).toMatch(/^owned-bindings-session-/) - expect(() => { bindReactLoopAgentContext(agent, new Context()) }).toThrow(/context is already bound/) + expect(agent.session.id).toBe(agent.id) + expect(() => { bindReactLoopAgentContext(agent as ReactLoopAgent, new Context()) }).toThrow(/context is already bound/) await ctx.fiber.dispose() }) @@ -86,14 +89,14 @@ describe('ReactLoopAgent', () => { it('send() throws after disposal', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('scoped'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) await fiber.dispose() - await agent.done + await driverDone(agent) expect(() => { agent.send([{ type: 'text', text: 'too late' }]) }).toThrow('disposed') }) @@ -101,14 +104,14 @@ describe('ReactLoopAgent', () => { it('steer() throws after disposal', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('scoped'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) await fiber.dispose() - await agent.done + await driverDone(agent) expect(() => { agent.steer([{ type: 'text', text: 'too late' }]) }).toThrow('disposed') }) @@ -116,14 +119,14 @@ describe('ReactLoopAgent', () => { it('inject() throws after disposal', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('scoped'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) await fiber.dispose() - await agent.done + await driverDone(agent) expect(() => { agent.inject([{ type: 'text', text: 'too late' }]) }).toThrow('disposed') }) @@ -131,7 +134,7 @@ describe('ReactLoopAgent', () => { it('inject() decides enclosure from the LOG (open turn), not agent status', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // Simulate an OPEN turn in the log while the agent is idle (status is not a // reliable open-turn signal). inject must append into that open turn, NOT @@ -157,7 +160,7 @@ describe('ReactLoopAgent', () => { // A persistence-like listener whose flush rejects. ctx.on('session/flush', () => { throw new Error('disk gone') }) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // inject() is synchronous and fires a fire-and-forget flush; a rejecting // flush must be contained (logged), never thrown into the caller. @@ -170,12 +173,14 @@ describe('ReactLoopAgent', () => { it('idle inject() closes its one-shot turn AND still checkpoints even if the append throws', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let flushes = 0 ctx.on('session/flush', () => { flushes += 1 }) - // Invalid injected content throws after turn/start. `finally` must still append turn/end and - // flush the balanced in-memory turn so a crash cannot lose it before the next checkpoint. + // Non-serializable injected content makes Session.append throw AFTER + // turn/start was recorded. The turn/end must still be appended (finally), + // AND the durability checkpoint must still fire — the balanced turn is in + // memory and a crash before the next turn/dispose would otherwise lose it. expect(() => { agent.inject([{ type: 'text', text: 'x', bad: 1n } as never], { source: { kind: 'plugin', plugin: 'p' } }) }).toThrow(/non-JSON-serializable/) @@ -188,7 +193,7 @@ describe('ReactLoopAgent', () => { it('idle inject() still checkpoints when a listener throws on the synthetic turn/end', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let flushes = 0 ctx.on('session/flush', () => { flushes += 1 }) // Session contains a throwing post-commit turn/end observer. The accepted @@ -211,7 +216,7 @@ describe('ReactLoopAgent', () => { // A non-Error rejection exercises the String() normalization branch. ctx.on('session/flush', () => { throw 'disk gone' }) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const errors: { turn: number; step: number; message: string }[] = [] ctx.on('agent/error', (_a, turn, step, error) => void errors.push({ turn, step, message: error.message })) @@ -230,7 +235,7 @@ describe('ReactLoopAgent', () => { it('idle inject() with a non-serializable source opens no turn (nothing to close)', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // A non-serializable source makes the turn/start append throw BEFORE the // event is pushed (Session.append validates before push), so NO turn opens. @@ -245,7 +250,7 @@ describe('ReactLoopAgent', () => { it('steer() when idle falls through to send() and starts a turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // steer while idle delegates to send agent.steer([{ type: 'text', text: 'steer idle' }], { source: { kind: 'plugin', plugin: 'test' } }) @@ -257,23 +262,29 @@ describe('ReactLoopAgent', () => { }) it('disposer is idempotent (double-stop)', async () => { - // The internal start seam exposes one idle driver's disposer for repeated invocation. + // Create a bare Agent and start it through the package-internal + // test seam. Then call its disposer twice — the second call hits the + // early-return branch. const ctx = new Context() await ctx.plugin(AgentExecutionProvider) await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('test')) const prepared = prepareReactLoopAgent( - ctx, AgentId('bare'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS, + ctx, SessionId('bare'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS, ) const { agent } = prepared + // Start the loop to get the disposer; the agent waits for messages + // (idle, never-resolving cancel), so it will stay idle. prepared.markPublished() const dispose = prepared.startDriver() + // First dispose const firstDisposal = dispose() expect(agent.status).toBe('disposed') await firstDisposal + // Second dispose — idempotent, no throw await expect(dispose()).resolves.toBeUndefined() expect(agent.status).toBe('disposed') }) @@ -283,7 +294,7 @@ describe('ReactLoopAgent', () => { await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('pre-start-dispose')) const prepared = prepareReactLoopAgent( - ctx, AgentId('pre-start-dispose'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS, + ctx, SessionId('pre-start-dispose'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS, ) await prepared.dispose() @@ -298,7 +309,7 @@ describe('ReactLoopAgent', () => { it('setting the same status does not emit agent/status again', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const statuses: string[] = [] ctx.on('agent/status', (subject, status) => { @@ -317,7 +328,7 @@ describe('ReactLoopAgent', () => { it('whenIdle() resolves immediately when the agent is not running', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // Fresh agent is idle — whenIdle() takes the not-running fast path and // resolves without subscribing. await must not hang. @@ -328,7 +339,7 @@ describe('ReactLoopAgent', () => { it('whenIdle() waits for queued work that has not flipped status yet', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'queued') let settled = false @@ -346,8 +357,8 @@ describe('ReactLoopAgent', () => { it('whenIdle() awaits the running→idle transition, ignoring other subjects/running events', async () => { const adapter = new MockAdapter([textResponse('ok'), textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) - const other = ctx.agentLoop.create(AgentId('a2'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const other = ctx.agentLoop.create(SessionId('a2'), { provider: 'mock', model: 'mock' }) // Drive `agent` into `running`, then await whenIdle() — it subscribes to // agent/status and resolves on the first transition out of running. @@ -370,8 +381,10 @@ describe('ReactLoopAgent', () => { }) it('whenIdle() subscribed while running resolves via done when the agent is then disposed', async () => { - // Queue the internal waiter while running, then dispose the bare driver. Its disposed branch - // must chain the loop's `done` promise rather than resolve before exit. + // Covers the waiter's disposed arm: whenIdle() queues an internal waiter + // while running (not the fast path), then the disposer settles it and chains + // `done` (loop exit), not an eager resolve. A bare Agent + direct + // internal driver disposer keeps the emit synchronous. const ctx = new Context() await ctx.plugin(AgentExecutionProvider) await ctx.plugin(LlmService) @@ -383,7 +396,7 @@ describe('ReactLoopAgent', () => { ctx.llm.registerAdapter(['mock'], adapter) const session = ctx.sessions.create(SessionId('bare')) const prepared = prepareReactLoopAgent( - ctx, AgentId('bare'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS, + ctx, SessionId('bare'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS, ) const { agent } = prepared prepared.markPublished() @@ -400,13 +413,15 @@ describe('ReactLoopAgent', () => { }) it('whenIdle() subscribed while running survives a FIBER dispose (no hung promise)', async () => { - // The waiter is agent-owned state, not an effect-scoped listener that owner disposal would - // remove before the disposed transition. Fiber teardown must still settle it. + // The waiter is internal agent state, NOT an effect-scoped ctx.on listener: + // disposing the OWNING fiber runs the agent's listener disposers, which would + // have dropped a ctx.on-based waiter before the 'disposed' transition and + // hung the promise. With internal waiters, the fiber disposer still settles it. const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('scoped'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -419,19 +434,21 @@ describe('ReactLoopAgent', () => { }) it('whenIdle() on a disposed agent awaits the loop exit (done), not just the status flip', async () => { - // Disposed status is emitted before the driver unwinds. `whenIdle()` must chain `done` so it - // resolves only after true loop exit. + // The disposer emits agent/status('disposed') BEFORE the driver loop + // unwinds, so whenIdle() must chain `done` (true quiescence) on the + // disposed path. Dispose a running agent, then assert whenIdle() resolves + // only after `done` — i.e. the loop has actually exited. const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('scoped'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) let doneResolved = false - void agent.done.then(() => { doneResolved = true }) + void driverDone(agent).then(() => { doneResolved = true }) await fiber.dispose() // sets status disposed, aborts, drains the loop expect(agent.status).toBe('disposed') @@ -446,7 +463,7 @@ describe('ReactLoopAgent', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/status', (_subject, status) => { if (status === 'running') throw new Error('bad running listener') }) @@ -464,7 +481,7 @@ describe('ReactLoopAgent', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/status', (_subject, status) => { if (status === 'idle') throw new Error('bad idle listener') }) diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 29a4b4e19b..e65ffc42a6 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -13,11 +13,15 @@ import LlmService, { type Message } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' +function driverDone(agent: Agent): Promise { + return (agent as Agent & { done: Promise }).done +} + async function harness(adapter: MockAdapter) { const ctx = new Context() await ctx.plugin(LlmService) @@ -31,12 +35,12 @@ async function harness(adapter: MockAdapter) { return ctx } -function send(agent: ReactLoopAgent, text: string) { +function send(agent: Agent, text: string) { agent.send([{ type: 'text', text }]) } /** Resolve on the agent's next idle transition (event-based, not status poll). */ -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { dispose(); resolve() } @@ -45,7 +49,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { } /** All user-message texts recorded in the log (to assert what actually ran). */ -function userTexts(agent: ReactLoopAgent): string[] { +function userTexts(agent: Agent): string[] { return agent.session.events .filter(e => e.type === 'user/message') .flatMap(e => e.type === 'user/message' ? e.data.content : []) @@ -56,7 +60,7 @@ describe('Agent.cancel()', () => { it('cancel() on an idle agent with nothing queued is a no-op; the next prompt runs (F2 leak guard)', async () => { const adapter = new MockAdapter([textResponse('reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // The loop is parked at the idle wait with nothing queued. A cancel here must // NOT arm the marker — otherwise the next legitimate prompt would be dropped. @@ -73,7 +77,7 @@ describe('Agent.cancel()', () => { it('pre-step cancel drops the about-to-start turn (no turn is opened)', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // send() queues synchronously (status still idle, loop microtask not yet // resumed). Cancel in that pre-step window: the queued turn must not run. @@ -92,7 +96,7 @@ describe('Agent.cancel()', () => { it('a whenIdle() waiter registered BEFORE a pre-step cancel resolves (F1 hang guard)', async () => { const adapter = new MockAdapter([textResponse('x')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // This waiter cannot rely on a running→idle transition because cancellation // drops the turn before it runs; the skip path must settle it directly. @@ -111,7 +115,7 @@ describe('Agent.cancel()', () => { it('cancel() mid-step aborts the in-flight model call; the turn ends aborted', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -128,7 +132,7 @@ describe('Agent.cancel()', () => { it('cancel() with no reason defaults to "cancelled" when aborting an in-flight step', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -144,7 +148,7 @@ describe('Agent.cancel()', () => { it('a prompt sent AFTER a cancelled turn settles runs normally (marker reset)', async () => { const adapter = new MockAdapter(['hang', textResponse('second reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // First turn hangs; cancel it mid-step. send(agent, 'first') @@ -166,7 +170,7 @@ describe('Agent.cancel()', () => { it('cancel from inside the agent/session-prefix waterfall drops the step (prefix-composition window)', async () => { const adapter = new MockAdapter([textResponse('should not stream')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // Prefix composition runs before the pre-step seam on the instance's first // step; a cancel landing inside it must drop the about-to-start step @@ -201,11 +205,10 @@ describe('Agent.cancel()', () => { ctx.llm.registerAdapter(['mock'], adapter) const handle = await ctx.agents.create({ - agentId: AgentId('a-dispose-prefix'), sessionId: SessionId('dispose-prefix-session'), agentOptions: { provider: 'mock', model: 'mock' }, }) - const agent = handle.agent as ReactLoopAgent + const agent = handle.agent let disposalDone: Promise | undefined let streamed = false @@ -218,7 +221,7 @@ describe('Agent.cancel()', () => { send(agent, 'go') await new Promise(resolve => setTimeout(resolve, 0)) await disposalDone - await agent.done + await driverDone(agent) // No step opened, no model call ran, and the turn closed disposed. expect(streamed).toBe(false) @@ -230,7 +233,7 @@ describe('Agent.cancel()', () => { it('a cancel-interrupted prefix composition is discarded: the next send recomposes and ships the fresh prefix (stale-cache guard)', async () => { const adapter = new MockAdapter([textResponse('reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // The interrupted first composition must not cache its degraded empty value; // the next prompt recomposes and logs/sends the fresh prefix. @@ -260,7 +263,7 @@ describe('Agent.cancel()', () => { it('cancel from a synchronous turn/start session-event listener drops the step (step-start window)', async () => { const adapter = new MockAdapter([textResponse('should not stream')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // A turn/start listener fires before a step controller exists, so the // turn-scoped marker—not step abort—must drop the pending step. @@ -287,7 +290,7 @@ describe('Agent.cancel()', () => { it('cancel from a synchronous step/start session-event listener drops the step (post-step-start window)', async () => { const adapter = new MockAdapter([textResponse('should not stream')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // A step/start session-event listener fires AFTER step/start is appended // (and after the pre-step seam), so cancelling there lands in the SECOND @@ -327,11 +330,10 @@ describe('Agent.cancel()', () => { ctx.llm.registerAdapter(['mock'], adapter) const handle = await ctx.agents.create({ - agentId: AgentId('a-dispose-step-start'), sessionId: SessionId('dispose-step-start-session'), agentOptions: { provider: 'mock', model: 'mock' }, }) - const agent = handle.agent as ReactLoopAgent + const agent = handle.agent let disposalDone: Promise | undefined let streamed = false @@ -342,7 +344,7 @@ describe('Agent.cancel()', () => { send(agent, 'go') await disposalDone - await agent.done + await driverDone(agent) expect(streamed).toBe(false) expect(adapter.requests).toHaveLength(0) @@ -359,7 +361,7 @@ describe('Agent.cancel()', () => { // `aborted` and run NO second step. const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let steps = 0 const reasons: TurnEndReason[] = [] @@ -391,7 +393,7 @@ describe('Agent.cancel()', () => { it('cancel from a synchronous agent/status(running) listener drops the turn (window 2)', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // `agent/status` is synchronous, so cancellation can land after the first // pre-step check; the second check must drop the now-empty turn. @@ -415,7 +417,7 @@ describe('Agent.cancel()', () => { // Cancellation must not settle idle while replacement work remains queued. const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let replaced = false const dispose = ctx.on('agent/status', (subject, status) => { @@ -442,7 +444,7 @@ describe('Agent.cancel()', () => { // prompt B is queued before the loop resumes from the idle wait. const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'A') // queues A (status still idle, loop microtask pending) const idle = agent.whenIdle() // registers a waiter (idle + hasQueued → no fast path) @@ -461,7 +463,7 @@ describe('Agent.cancel()', () => { it("cancel clears the turn's steering — it is not re-enqueued as a fresh turn", async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 4912f7a44d..7badab97c1 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -7,16 +7,17 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' + import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' const dirs: string[] = [] afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) }) -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { dispose(); resolve() } @@ -24,7 +25,281 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } +async function makeCoreContext(): Promise { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + return ctx +} + describe('config-driven session id', () => { + it('rejects an empty exact id before publishing an agent', async () => { + const ctx = await makeCoreContext() + await expect(ctx.plugin(AgentLoop, { + agents: [{ id: 'main', sessionId: SessionId(''), model: 'mock' }], + })).rejects.toThrow('expected string length >= 1') + expect(ctx.agents.get(SessionId(''))).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('accepts one exact fresh id and rejects it alongside a resume id', async () => { + const exact = await makeCoreContext() + await exact.plugin(AgentLoop, { + agents: [{ id: 'main', sessionId: SessionId('stdio-exact'), model: 'mock' }], + }) + expect(exact.agents.get(SessionId('stdio-exact'))?.session.id).toBe('stdio-exact') + await exact.fiber.dispose() + + const conflicting = await makeCoreContext() + await expect(conflicting.plugin(AgentLoop, { + agents: [{ + id: 'main', + sessionId: SessionId('fresh'), + resumeSessionId: SessionId('persisted'), + model: 'mock', + }], + })).rejects.toThrow('sessionId and resumeSessionId are mutually exclusive') + await conflicting.fiber.dispose() + }) + + it('rejects duplicate exact ids before asynchronous configured startup', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-duplicate-')) + dirs.push(root) + const ctx = await makeCoreContext() + await ctx.plugin(SessionPersistenceJsonl, { root }) + + const outcome = await ctx.plugin(AgentLoop, { + agents: [ + { id: 'first', sessionId: SessionId('shared'), model: 'mock' }, + { id: 'second', sessionId: SessionId('shared'), model: 'mock' }, + ], + }).then(() => undefined, (error: unknown) => error) + const published = ctx.agents.get(SessionId('shared')) + await ctx.fiber.dispose() + + expect(outcome).toEqual(new Error('agents "first" and "second" use duplicate exact session identity "shared"')) + expect(published).toBeUndefined() + }) + + it('restores a materialized exact id across an AgentLoop-only reload', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-reload-')) + dirs.push(root) + const ctx = await makeCoreContext() + await ctx.plugin(SessionPersistenceJsonl, { root }) + ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first'), textResponse('second')])) + const config = { agents: [{ id: 'main', sessionId: SessionId('stdio-exact-reload'), model: 'mock' }] } + + const firstLoop = await ctx.plugin(AgentLoop, config) + let first: Agent | undefined + for (let i = 0; i < 50 && first === undefined; i++) { + await new Promise(resolve => setTimeout(resolve, 5)) + first = ctx.agents.get(SessionId('stdio-exact-reload')) + } + expect(first).toBeDefined() + first!.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } }) + await waitForIdle(ctx, first!) + await firstLoop.dispose() + + const secondLoop = await ctx.plugin(AgentLoop, config) + let second: Agent | undefined + for (let i = 0; i < 50 && second === undefined; i++) { + await new Promise(resolve => setTimeout(resolve, 5)) + second = ctx.agents.get(SessionId('stdio-exact-reload')) + } + expect(second).toBeDefined() + expect(JSON.stringify(second!.session.deriveMessages())).toContain('remember me') + second!.send([{ type: 'text', text: 'continue' }], { source: { kind: 'user' } }) + await waitForIdle(ctx, second!) + await ctx.sessions.flush(second!.session) + const loaded = await ctx.sessionPersistence.load(SessionId('stdio-exact-reload')) + expect(loaded.events.filter(event => event.type === 'turn/start')).toHaveLength(2) + + await secondLoop.dispose() + await ctx.fiber.dispose() + }) + + it('waits for a draining exact-id lifecycle during an overlapping reload', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-overlap-')) + dirs.push(root) + const ctx = await makeCoreContext() + await ctx.plugin(SessionPersistenceJsonl, { root }) + const sessionId = SessionId('stdio-exact-overlap') + const config = { agents: [{ id: 'main', sessionId, model: 'mock' }] } + const firstLoop = await ctx.plugin(AgentLoop, config) + await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined() + const first = ctx.agents.get(sessionId) as Agent + + const flushGate = Promise.withResolvers() + let flushStarted = false + ctx.on('session/flush', (session) => { + if (session !== first.session) return + flushStarted = true + return flushGate.promise + }) + first.inject([{ type: 'text', text: 'persist before replacement' }], { + source: { kind: 'plugin', plugin: 'test' }, + }) + expect(flushStarted).toBe(true) + + const firstDisposal = firstLoop.dispose() + await expect.poll(() => first.status).toBe('disposed') + const failures: unknown[] = [] + ctx.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) }) + const secondLoop = await ctx.plugin(AgentLoop, config) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(ctx.agents.get(sessionId)).toBe(first) + expect(failures).toEqual([]) + + flushGate.resolve(undefined) + await firstDisposal + await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined() + const second = ctx.agents.get(sessionId) as Agent + expect(second).not.toBe(first) + expect(JSON.stringify(second.session.deriveMessages())).toContain('persist before replacement') + expect(failures).toEqual([]) + + await secondLoop.dispose() + await ctx.fiber.dispose() + }) + + it('cancels an exact-id reload while the prior lifecycle is still draining', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-cancel-')) + dirs.push(root) + const ctx = await makeCoreContext() + await ctx.plugin(SessionPersistenceJsonl, { root }) + const sessionId = SessionId('stdio-exact-cancel') + const config = { agents: [{ id: 'main', sessionId, model: 'mock' }] } + const firstLoop = await ctx.plugin(AgentLoop, config) + await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined() + const first = ctx.agents.get(sessionId) as Agent + + const flushGate = Promise.withResolvers() + ctx.on('session/flush', (session) => { + if (session === first.session) return flushGate.promise + }) + first.inject([{ type: 'text', text: 'persist before cancellation' }], { + source: { kind: 'plugin', plugin: 'test' }, + }) + + const firstDisposal = firstLoop.dispose() + await expect.poll(() => first.status).toBe('disposed') + const secondLoop = await ctx.plugin(AgentLoop, config) + await secondLoop.dispose() + expect(ctx.agents.get(sessionId)).toBe(first) + + flushGate.resolve(undefined) + await firstDisposal + expect(ctx.agents.get(sessionId)).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('contains an exact-id persistence lookup failure', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-failure-')) + dirs.push(root) + const ctx = await makeCoreContext() + await ctx.plugin(SessionPersistenceJsonl, { root }) + const failure = new Error('persistence index failed') + const listenerFailure = new Error('failure observer failed') + const asyncListenerFailure = new Error('async failure observer failed') + const failures: { sessionId: SessionId; error: unknown }[] = [] + ctx.on('agent-loop/config-start-failed', () => { throw listenerFailure }) + ctx.on('agent-loop/config-start-failed', () => Promise.reject(asyncListenerFailure) as never) + ctx.on('agent-loop/config-start-failed', (sessionId, error) => { + failures.push({ sessionId, error }) + }) + vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(failure) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + + await ctx.plugin(AgentLoop, { + agents: [{ id: 'main', sessionId: SessionId('stdio-exact-failure'), model: 'mock' }], + }) + + await expect.poll(() => warn).toHaveBeenCalledWith(expect.stringContaining( + 'config-driven restore of "stdio-exact-failure" failed: Error: persistence index failed', + )) + expect(failures).toEqual([{ sessionId: SessionId('stdio-exact-failure'), error: failure }]) + expect(warn).toHaveBeenCalledWith( + 'agent "main": config-start-failed listener threw: Error: failure observer failed', + ) + await expect.poll(() => warn).toHaveBeenCalledWith( + 'agent "main": config-start-failed listener rejected: Error: async failure observer failed', + ) + expect(ctx.agents.get(SessionId('stdio-exact-failure'))).toBeUndefined() + warn.mockRestore() + await ctx.fiber.dispose() + }) + + it('contains startup and observer failures whose string coercion throws', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-unrenderable-')) + dirs.push(root) + const ctx = await makeCoreContext() + await ctx.plugin(SessionPersistenceJsonl, { root }) + const unrenderable = { + [Symbol.toPrimitive](): never { + throw new Error('coercion escaped') + }, + } + const failures: unknown[] = [] + ctx.on('agent-loop/config-start-failed', () => { throw unrenderable }) + // Deliberately violate the normal Error-only rejection rule to exercise the unknown boundary. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + ctx.on('agent-loop/config-start-failed', () => Promise.reject(unrenderable) as never) + ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) }) + vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(unrenderable) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + + await ctx.plugin(AgentLoop, { + agents: [{ id: 'main', sessionId: SessionId('stdio-exact-unrenderable'), model: 'mock' }], + }) + + await expect.poll(() => failures).toEqual([unrenderable]) + expect(warn).toHaveBeenCalledWith( + 'agent "main": config-driven restore of "stdio-exact-unrenderable" failed: ', + ) + expect(warn).toHaveBeenCalledWith( + 'agent "main": config-start-failed listener threw: ', + ) + await expect.poll(() => warn).toHaveBeenCalledWith( + 'agent "main": config-start-failed listener rejected: ', + ) + await ctx.fiber.dispose() + }) + + it.each(['resolve', 'reject'] as const)( + 'joins an exact-id persistence lookup that will %s before AgentLoop disposal completes', + async (outcome) => { + const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-dispose-')) + dirs.push(root) + const ctx = await makeCoreContext() + await ctx.plugin(SessionPersistenceJsonl, { root }) + const listing = Promise.withResolvers>>() + vi.spyOn(ctx.sessionPersistence, 'list').mockReturnValue(listing.promise) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + const failures: unknown[] = [] + ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) }) + + const loop = await ctx.plugin(AgentLoop, { + agents: [{ id: 'main', sessionId: SessionId('stdio-exact-dispose'), model: 'mock' }], + }) + let disposed = false + const disposal = loop.dispose().then(() => { disposed = true }) + await Promise.resolve() + expect(disposed).toBe(false) + + if (outcome === 'resolve') listing.resolve([]) + else listing.reject(new Error('startup cancelled by teardown')) + await disposal + expect(ctx.agents.get(SessionId('stdio-exact-dispose'))).toBeUndefined() + expect(failures).toEqual([]) + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + await ctx.fiber.dispose() + }, + ) + it('identity-nests the deferred resume fiber under its labeled owner effect', async () => { const ctx = new Context() await ctx.plugin(LlmService) @@ -34,7 +309,7 @@ describe('config-driven session id', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(AgentExecutionProvider) const loopFiber = await ctx.plugin(AgentLoop, { - agents: [{ id: AgentId('main'), provider: 'mock', model: 'mock', resumeSessionId: SessionId('deferred') }], + agents: [{ id: SessionId('main'), provider: 'mock', model: 'mock', resumeSessionId: SessionId('deferred') }], }) const resumeEffect = loopFiber.getEffects().find(effect => effect.label === 'agentLoop.resume(main)') @@ -56,11 +331,13 @@ describe('config-driven session id', () => { await ctx1.plugin(ToolRegistry) await ctx1.plugin(AgentRegistry) await ctx1.plugin(AgentExecutionProvider) - await ctx1.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), provider: 'mock', model: 'mock' }] }) + await ctx1.plugin(AgentLoop, { agents: [{ id: SessionId('cfg'), provider: 'mock', model: 'mock' }] }) await ctx1.plugin(SessionPersistenceJsonl, { root }) ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg')])) - const a1 = ctx1.agents.get(AgentId('cfg')) as ReactLoopAgent + const a1 = ctx1.agents.list()[0] as Agent + expect(a1.id).toBe(a1.session.id) expect(a1.session.id).toMatch(idPattern) + expect(ctx1.agents.get(SessionId('cfg'))).toBeUndefined() a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -74,10 +351,11 @@ describe('config-driven session id', () => { await ctx2.plugin(ToolRegistry) await ctx2.plugin(AgentRegistry) await ctx2.plugin(AgentExecutionProvider) - await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), provider: 'mock', model: 'mock' }] }) + await ctx2.plugin(AgentLoop, { agents: [{ id: SessionId('cfg'), provider: 'mock', model: 'mock' }] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg2')])) - const a2 = ctx2.agents.get(AgentId('cfg')) as ReactLoopAgent + const a2 = ctx2.agents.list()[0] as Agent + expect(a2.id).toBe(a2.session.id) expect(a2.session.id).toMatch(idPattern) expect(a2.session.id).not.toBe(a1.session.id) a2.send([{ type: 'text', text: 'q2' }], { source: { kind: 'user' } }) @@ -101,7 +379,7 @@ describe('config-driven session id', () => { await ctx1.plugin(AgentLoop, { agents: [] }) await ctx1.plugin(SessionPersistenceJsonl, { root }) ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')])) - const a1 = (await ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sticky-1') })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('sticky-1') })).agent a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -115,19 +393,20 @@ describe('config-driven session id', () => { await ctx2.plugin(ToolRegistry) await ctx2.plugin(AgentRegistry) await ctx2.plugin(AgentExecutionProvider) - await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('main'), provider: 'mock', model: 'mock', resumeSessionId: SessionId('sticky-1') }] }) + await ctx2.plugin(AgentLoop, { agents: [{ id: SessionId('main'), provider: 'mock', model: 'mock', resumeSessionId: SessionId('sticky-1') }] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')])) // The deferred resume runs on a microtask after the backend is available. - let resumed: ReactLoopAgent | undefined + let resumed: Agent | undefined for (let i = 0; i < 50 && !resumed; i++) { await new Promise(r => setTimeout(r, 5)) - resumed = ctx2.agents.get(AgentId('main')) as ReactLoopAgent | undefined + resumed = ctx2.agents.get(SessionId('sticky-1')) } expect(resumed).toBeDefined() // The live session id IS the resumed id (NOT a fresh ${id}-session-), // and the prior turn's user message is in the derived history. + expect(resumed!.id).toBe(SessionId('sticky-1')) expect(resumed!.session.id).toBe('sticky-1') const derived = resumed!.session.deriveMessages() expect(JSON.stringify(derived)).toContain('remember me') @@ -144,16 +423,16 @@ describe('config-driven session id', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentExecutionProvider) - await ctx.plugin(AgentLoop, { agents: [{ id: AgentId('main'), provider: 'mock', model: 'mock', resumeSessionId: SessionId('does-not-exist') }] }) + await ctx.plugin(AgentLoop, { agents: [{ id: SessionId('main'), provider: 'mock', model: 'mock', resumeSessionId: SessionId('does-not-exist') }] }) const warn = vi.spyOn((ctx.agentLoop as unknown as { ctx: { logger: { warn: (...a: unknown[]) => void } } }).ctx.logger, 'warn') .mockImplementation(() => undefined) await ctx.plugin(SessionPersistenceJsonl, { root }) ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('x')])) // The deferred resume fails (no such session on disk). It must be contained: - // a warning is logged, no 'main' agent is registered, and the app stays up. + // a warning is logged, no agent is registered, and the app stays up. await new Promise(r => setTimeout(r, 200)) - expect(ctx.agents.get(AgentId('main'))).toBeUndefined() + expect(ctx.agents.list()).toEqual([]) expect(warn).toHaveBeenCalledWith(expect.stringContaining('config-driven resume of "does-not-exist" failed')) warn.mockRestore() await ctx.fiber.dispose() diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index f2c2d448b7..891272a3c2 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -4,13 +4,17 @@ import LlmService, { CallId, ContentBlock, MessageSource, StreamChunk } from '@d import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool, type PostToolDecision } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId, type ContinuationDecision } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent, type ContinuationDecision } from '@deepseek-ai/dsh-agent' import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' -import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS, ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop' import { prepareReactLoopAgent } from '../src/agent.ts' import * as Invariants from '@deepseek-ai/dsh-invariants' import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' +function driverDone(agent: Agent): Promise { + return (agent as Agent & { done: Promise }).done +} + /** Regression tests for agent-loop boundary, identity, and lifecycle contracts. */ async function harness(adapter: MockAdapter) { @@ -26,7 +30,7 @@ async function harness(adapter: MockAdapter) { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -37,7 +41,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } -function send(agent: ReactLoopAgent, text: string) { +function send(agent: Agent, text: string) { agent.send([{ type: 'text', text }]) } @@ -57,7 +61,7 @@ describe('session log records what agent/step-result actually produced', () => { return [{ type: 'text', text: 'ran' }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // Plugin rewrites the message: replaces the text AND adds a tool call. let rewritten = false @@ -99,7 +103,7 @@ describe('session log records what agent/step-result actually produced', () => { response[response.length - 1] = { type: 'finish', reason: { kind: 'stop' }, replayState } const adapter = new MockAdapter([response]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('replay-state'), { provider: 'mock', model: 'next-model' }) + const agent = ctx.agentLoop.create(SessionId('replay-state'), { provider: 'mock', model: 'next-model' }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -123,7 +127,7 @@ describe('session log records what agent/step-result actually produced', () => { if (block?.type === 'text') block.text = 'mutated' return message }) - const agent = ctx.agentLoop.create(AgentId('mutated-replay-state'), { provider: 'mock', model: 'next-model' }) + const agent = ctx.agentLoop.create(SessionId('mutated-replay-state'), { provider: 'mock', model: 'next-model' }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -143,7 +147,7 @@ describe('successful provider completion survives agent/step-result failure', () const adapter = new MockAdapter([response]) const ctx = await harness(adapter) await ctx.plugin(Invariants) - const agent = ctx.agentLoop.create(AgentId(id), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId(id), { provider: 'mock', model: 'mock' }) const failure = new Error(`${id} result processing failed`) const reported: Error[] = [] @@ -216,7 +220,7 @@ describe('abort during tool execution ends the turn', () => { ]) const ctx = await harness(adapter) const executed: string[] = [] - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.tools.register(defineTool({ name: 'aborter', description: '', @@ -255,7 +259,7 @@ describe('abort during tool execution ends the turn', () => { it('records context accepted before a tool-step abort in the same turn', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'aborter', {})]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-abort-injection'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-abort-injection'), { provider: 'mock', model: 'mock' }) ctx.tools.register(defineTool({ name: 'aborter', description: '', @@ -301,7 +305,7 @@ describe('abort during tool execution ends the turn', () => { { type: 'finish', reason: { kind: 'tool-calls' } }, ] satisfies StreamChunk[]]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-later-abort-context'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-later-abort-context'), { provider: 'mock', model: 'mock' }) ctx.tools.register(defineTool({ name: 'first', description: '', @@ -347,9 +351,9 @@ describe('abort during tool execution ends the turn', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'waiter', {})]) const ctx = await harness(adapter) const started = Promise.withResolvers() - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('a-dispose-injection'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('a-dispose-injection'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) ctx.tools.register(defineTool({ name: 'waiter', @@ -402,7 +406,7 @@ describe('abort during tool execution ends the turn', () => { textResponse('later turn'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-historical-tool-pair'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-historical-tool-pair'), { provider: 'mock', model: 'mock' }) ctx.tools.register(defineTool({ name: 'aborter', description: '', @@ -444,7 +448,7 @@ describe('steering from late extension points is never stranded', () => { textResponse('continued because of steering'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let steeredOnce = false ctx.on('agent/turn-continuation', async (_agent, _turn, _decision, next) => { @@ -470,7 +474,7 @@ describe('steering from late extension points is never stranded', () => { textResponse('after goal reminder'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let steeredOnce = false ctx.on('session/event', (subject, event) => { @@ -498,7 +502,7 @@ describe('steering from late extension points is never stranded', () => { it('steer() from a turn/end session-event listener becomes a queued message for the next turn', async () => { const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const turns: number[] = [] let steeredOnce = false @@ -524,7 +528,7 @@ describe('steering from late extension points is never stranded', () => { it('steering queued during an aborted step is re-delivered, not silently consumed', async () => { const adapter = new MockAdapter(['hang', textResponse('recovered')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -547,7 +551,7 @@ describe('plugin exceptions are contained', () => { it('a throwing agent/turn-continuation listener ends the turn with an error, loop survives', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let threwOnce = false ctx.on('agent/turn-continuation', async (): Promise => { @@ -575,7 +579,7 @@ describe('plugin exceptions are contained', () => { it('a rejecting session/flush listener is reported but does not kill the agent', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let rejectedOnce = false ctx.on('session/flush', async () => { @@ -603,9 +607,9 @@ describe('disposed status is part of the agent/status contract', () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('scoped'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) const statuses: string[] = [] @@ -616,7 +620,7 @@ describe('disposed status is part of the agent/status contract', () => { send(agent, 'go') await new Promise(r => setTimeout(r, 30)) await fiber.dispose() - await agent.done + await driverDone(agent) expect(statuses).toEqual(['running', 'disposed']) expect(reasons).toEqual([{ kind: 'disposed' }]) @@ -626,9 +630,9 @@ describe('disposed status is part of the agent/status contract', () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('scoped'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) ctx.on('agent/status', (_agent, status) => { @@ -638,10 +642,10 @@ describe('disposed status is part of the agent/status contract', () => { send(agent, 'go') await new Promise(r => setTimeout(r, 30)) await fiber.dispose() - await agent.done // must not hang + await driverDone(agent) // must not hang expect(agent.status).toBe('disposed') - expect(ctx.agents.get(AgentId('scoped'))).toBeUndefined() // unregistered despite the throw + expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined() // unregistered despite the throw }) }) @@ -660,7 +664,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { it('an agent without a model fails the step with a clear error (not NO_ADAPTER for "default")', async () => { const adapter = new MockAdapter([textResponse('never')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), {}) // no model + const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model const errors: Error[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) @@ -675,7 +679,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { it('the agent/request waterfall can supply the model for a model-less agent', async () => { const adapter = new MockAdapter([textResponse('routed')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), {}) // no model — router plugin decides + const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model — router plugin decides ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => { return { ...config, provider: 'mock', model: 'mock' } @@ -690,7 +694,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { it('agent/queued carries the resolved source; steering/message records its source', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.tools.register(defineTool({ name: 'noop', description: '', @@ -718,7 +722,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { it('send() owns content and source before notification and delivery', async () => { const adapter = new MockAdapter([textResponse('done')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('owned-send'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('owned-send'), { provider: 'mock', model: 'mock' }) const content = [{ type: 'text' as const, text: 'accepted-send' }] const source = { kind: 'plugin' as const, plugin: 'accepted-source' } let notifiedContent: ContentBlock[] | undefined @@ -754,7 +758,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { it('running steer() owns content and source before notification and delivery', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'gate', {}), textResponse('done')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('owned-steer'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('owned-steer'), { provider: 'mock', model: 'mock' }) const entered = Promise.withResolvers() const release = Promise.withResolvers() ctx.tools.register(defineTool({ @@ -808,7 +812,7 @@ describe('turn numbering continues across seeded sessions', () => { it('a forked agent continues turn numbers after the seed log', async () => { const first = new MockAdapter([textResponse('turn one')]) const ctx = await harness(first) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -826,7 +830,7 @@ describe('turn numbering continues across seeded sessions', () => { const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] }) const prepared = prepareReactLoopAgent( - ctx2, AgentId('forked-agent'), { provider: 'mock', model: 'mock' }, seeded, DEFAULT_MAX_PARALLEL_TOOL_CALLS, + ctx2, SessionId('forked-agent'), { provider: 'mock', model: 'mock' }, seeded, DEFAULT_MAX_PARALLEL_TOOL_CALLS, ) const forked = prepared.agent prepared.markPublished() @@ -871,7 +875,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () ] const adapter = new MockAdapter([errorStream]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-finish-error'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-finish-error'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -896,7 +900,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () ] const adapter = new MockAdapter([abortedStream]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-finish-aborted'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-finish-aborted'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -914,7 +918,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () ] const adapter = new MockAdapter([errorStream]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-finish-error-nocode'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-finish-error-nocode'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -930,7 +934,7 @@ describe('step boundary publication order', () => { it('the step/start event is in session.events when its session/event listener fires', async () => { const adapter = new MockAdapter([textResponse('done')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-step-order'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-step-order'), { provider: 'mock', model: 'mock' }) // Append commits before observers run. const observed: { turn: number; step: number; lastEventType: string | undefined; sawStepStart: boolean }[] = [] @@ -971,7 +975,7 @@ describe('turn and step boundary recovery', () => { } /** Count turn/step boundary events for balance assertions. */ - function boundaryCounts(agent: ReactLoopAgent) { + function boundaryCounts(agent: Agent) { const e = [...agent.session.events] return { turnStart: e.filter(x => x.type === 'turn/start').length, @@ -986,7 +990,7 @@ describe('turn and step boundary recovery', () => { it('a throwing step/start observer cannot change a successful turn', async () => { const adapter = new MockAdapter([textResponse('request completed')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-stepstart'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-stepstart'), { provider: 'mock', model: 'mock' }) // Session owns post-commit containment. The loop sees a successful append, // runs the request, and balances the ordinary step and turn boundaries. @@ -1015,7 +1019,7 @@ describe('turn and step boundary recovery', () => { it('a pre-commit step/start validation failure does not invent a step boundary', async () => { const adapter = new MockAdapter([textResponse('never reached')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-stepstart-veto'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-stepstart-veto'), { provider: 'mock', model: 'mock' }) let rejected = false ctx.on('internal/dispatch', (_mode, name, args) => { if (name !== 'session/event') return @@ -1046,7 +1050,7 @@ describe('turn and step boundary recovery', () => { const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider failed' } }] const adapter = new MockAdapter([errorStream]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-turnend-veto'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-turnend-veto'), { provider: 'mock', model: 'mock' }) let rejected = false ctx.on('internal/dispatch', (_mode, name, args) => { if (name !== 'session/event') return @@ -1080,7 +1084,7 @@ describe('turn and step boundary recovery', () => { it('a one-shot step/end validation failure keeps the step open until retry succeeds', async () => { const adapter = new MockAdapter([textResponse('completed before close validation')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-stepend-veto'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-stepend-veto'), { provider: 'mock', model: 'mock' }) let rejected = false ctx.on('internal/dispatch', (_mode, name, args) => { if (name !== 'session/event') return @@ -1112,7 +1116,7 @@ describe('turn and step boundary recovery', () => { const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }] const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-errorlistener'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-errorlistener'), { provider: 'mock', model: 'mock' }) let threw = false ctx.on('agent/error', () => { if (!threw) { threw = true; throw new Error('boom error-listener') } }) @@ -1143,9 +1147,9 @@ describe('turn and step boundary recovery', () => { // balanced with reason disposed (no error event for a disposal). const adapter = new MockAdapter(['hang']) const ctx = await balancedHarness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('a-dispose'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('a-dispose'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -1154,7 +1158,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'go') await new Promise(r => setTimeout(r, 30)) await fiber.dispose() // dispose during the hanging step - await agent.done + await driverDone(agent) const e = [...agent.session.events] const turnStarts = e.filter(x => x.type === 'turn/start').length @@ -1170,9 +1174,9 @@ describe('turn and step boundary recovery', () => { // Disposal remains authoritative when the listener also throws. const adapter = new MockAdapter([textResponse('never reached')]) const ctx = await balancedHarness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('a-prestep-dispose-throw'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('a-prestep-dispose-throw'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) let threw = false @@ -1186,7 +1190,7 @@ describe('turn and step boundary recovery', () => { ctx.on('agent/error', (_a, _t, _s, error) => void errorEmits.push(error)) send(agent, 'go') - await agent.done + await driverDone(agent) const e = [...agent.session.events] // Balanced: one turn/start, one turn/end carrying disposed (NOT error). @@ -1203,7 +1207,7 @@ describe('turn and step boundary recovery', () => { it('a throwing turn/start observer cannot starve the loop or later turns', async () => { const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-preturn'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-preturn'), { provider: 'mock', model: 'mock' }) let threw = false ctx.on('session/event', (_session, event) => { @@ -1234,7 +1238,7 @@ describe('turn and step boundary recovery', () => { it('a throwing step/end observer cannot rewrite the turn outcome', async () => { const adapter = new MockAdapter([textResponse('all good'), textResponse('turn 2 ok')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-stepend-throw'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-stepend-throw'), { provider: 'mock', model: 'mock' }) let threw = false ctx.on('session/event', (_s, event) => { @@ -1273,7 +1277,7 @@ describe('turn and step boundary recovery', () => { const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }] const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-stependthrow'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-stependthrow'), { provider: 'mock', model: 'mock' }) let threw = false ctx.on('session/event', (_s, event) => { @@ -1303,7 +1307,7 @@ describe('turn and step boundary recovery', () => { // boundary stays authoritative and the loop continues normally. const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-turnendappend'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-turnendappend'), { provider: 'mock', model: 'mock' }) let threw = false ctx.on('session/event', (_s, event) => { @@ -1349,7 +1353,7 @@ describe('tool result call identity', () => { return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'ok' }] }) }, { prepend: true }) - const agent = ctx.agentLoop.create(AgentId('a-callid'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-callid'), { provider: 'mock', model: 'mock' }) send(agent, 'use tool') await waitForIdle(ctx, agent) @@ -1380,7 +1384,7 @@ describe('surface: assistant/message records exact empty provenance when no chun const adapter = new MockAdapter([[]]) const ctx = await harness(adapter) await ctx.plugin(Invariants) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _next) => ({ role: 'assistant' as const, @@ -1426,9 +1430,9 @@ describe('disposal and cancellation during pre-step assembly', () => { return next() }) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('a-dispose-assemble'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('a-dispose-assemble'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -1443,7 +1447,7 @@ describe('disposal and cancellation during pre-step assembly', () => { releaseAssemble() await disposalDone - await agent.done + await driverDone(agent) unlisten() // Turn boundaries are durable rows; there is no `agent/*` mirror to assert. @@ -1477,9 +1481,9 @@ describe('disposal and cancellation during pre-step assembly', () => { return next() }) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('a-cancel-assemble'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('a-cancel-assemble'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -1492,7 +1496,7 @@ describe('disposal and cancellation during pre-step assembly', () => { releaseAssemble() await waitForIdle(ctx, agent) await fiber.dispose() - await agent.done + await driverDone(agent) unlisten() const e = [...agent.session.events] @@ -1532,9 +1536,9 @@ describe('disposal and cancellation during pre-step assembly', () => { await blocker }) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('a-dispose-prestep'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('a-dispose-prestep'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -1547,7 +1551,7 @@ describe('disposal and cancellation during pre-step assembly', () => { const disposalDone = fiber.dispose() releasePreStep() await disposalDone - await agent.done + await driverDone(agent) // After the pre-step seam finishes, the post-seam cancel/dispose check // catches disposal. The step was never opened, no LLM call was made. @@ -1584,9 +1588,9 @@ describe('disposal and cancellation during pre-step assembly', () => { await blocker }) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('a-cancel-prestep'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('a-cancel-prestep'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -1599,7 +1603,7 @@ describe('disposal and cancellation during pre-step assembly', () => { releasePreStep() await waitForIdle(ctx, agent) await fiber.dispose() - await agent.done + await driverDone(agent) const e = [...agent.session.events] expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) @@ -1635,9 +1639,9 @@ describe('disposal and cancellation during pre-step assembly', () => { return next() }) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('a-dispose-no-leak'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('a-dispose-no-leak'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') @@ -1646,7 +1650,7 @@ describe('disposal and cancellation during pre-step assembly', () => { const disposalDone = fiber.dispose() releaseAssemble() await disposalDone - await agent.done + await driverDone(agent) const e = [...agent.session.events] expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 6d42c4090d..d7971c81bc 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -1,15 +1,19 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService, { CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm' -import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' +function driverDone(agent: Agent): Promise { + return (agent as Agent & { done: Promise }).done +} + async function harness(adapter: MockAdapter) { const ctx = new Context() await ctx.plugin(LlmService) @@ -23,7 +27,7 @@ async function harness(adapter: MockAdapter) { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -34,7 +38,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } -function send(agent: ReactLoopAgent, text: string) { +function send(agent: Agent, text: string) { agent.send([{ type: 'text', text }]) } @@ -42,7 +46,7 @@ describe('inbox acceptance', () => { it('rejects non-serializable content or source synchronously before notification or enqueue', async () => { const adapter = new MockAdapter([textResponse('turn 1')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let queued = 0 ctx.on('agent/queued', () => { queued += 1 }) @@ -82,7 +86,7 @@ describe('tool JSON parse', () => { return [{ type: 'text', text: typeof args === 'string' ? `raw: ${args}` : JSON.stringify(args) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'use tool') await waitForIdle(ctx, agent) @@ -115,7 +119,7 @@ describe('tool JSON parse', () => { return [{ type: 'text', text: 'ran with empty args' }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'use tool') await waitForIdle(ctx, agent) @@ -128,7 +132,7 @@ describe('toError normalization', () => { it('normalizes non-Error throws from pre-commit dispatch validation via the runLoop backstop', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let threwOnce = false ctx.on('internal/dispatch', (_mode, name, args) => { @@ -154,7 +158,7 @@ describe('toError normalization', () => { it('normalizes non-Error throws from agent/request waterfall via inline toError in runStep catch', async () => { const adapter = new MockAdapter([textResponse('irrelevant')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let threwOnce = false ctx.on('agent/request', async (_agent, _turn, _step, _options, _next) => { @@ -182,7 +186,7 @@ describe('coded error data emission', () => { it('errorData includes code when a coded error (LlmError) is thrown from a plugin', async () => { const adapter = new MockAdapter([textResponse('turn 1')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let threwOnce = false ctx.on('agent/request', async (_agent, _turn, _step, _options, next) => { @@ -214,9 +218,9 @@ describe('disposed vs aborted branching', () => { it('handles dispose during model streaming producing reason "disposed"', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('scoped'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -225,7 +229,7 @@ describe('disposed vs aborted branching', () => { send(agent, 'go') await new Promise(r => setTimeout(r, 30)) await fiber.dispose() // dispose during hang - await agent.done + await driverDone(agent) // Disposal wins abort classification because the error path checks it first. expect(reasons).toContainEqual({ kind: 'disposed' }) @@ -242,7 +246,7 @@ describe('structured tool error propagation (the runtime-validation RFC, part 2) textResponse('done'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.tools.register(defineTool({ name: 'boom', description: 'always fails', diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 06991e7ec5..d3c66af720 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -1,17 +1,12 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService, { CallId, type Message } from '@deepseek-ai/dsh-llm' -import SessionStore, { type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { - AgentId, - type ContinuationDecision, - type PromptDecision, - type SessionStartSource, -} from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent, type ContinuationDecision, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent' import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' /** @@ -36,7 +31,7 @@ async function harness(adapter: MockAdapter) { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -47,11 +42,11 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } -function send(agent: ReactLoopAgent, text: string) { +function send(agent: Agent, text: string) { agent.send([{ type: 'text', text }]) } -function events(agent: ReactLoopAgent): SessionEvent[] { +function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } @@ -59,7 +54,7 @@ describe('agent/prompt-submit', () => { it('allow (default via next) records the user/message unchanged', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const seen: string[] = [] ctx.on('agent/prompt-submit', async (_agent, content, _source, next) => { @@ -78,7 +73,7 @@ describe('agent/prompt-submit', () => { it('allow with content REWRITES the prompt before it is recorded', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/prompt-submit', async (): Promise => ({ kind: 'allow', content: [{ type: 'text', text: 'REWRITTEN' }] })) @@ -96,7 +91,7 @@ describe('agent/prompt-submit', () => { it('allow with additionalContexts injects separate context/message events into the turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const meta = { kind: 'prompt-context', version: 1 } ctx.on('agent/prompt-submit', async (): Promise => @@ -131,7 +126,7 @@ describe('agent/prompt-submit', () => { // compaction listener measures the current surface before the single derive. const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/prompt-submit', async (): Promise => ({ @@ -161,7 +156,7 @@ describe('agent/prompt-submit', () => { it('block drops the (only) prompt → zero-step turn ends rejected, model never called', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/prompt-submit', async (): Promise => ({ kind: 'block', reason: 'blocked by policy' })) @@ -197,7 +192,7 @@ describe('agent/prompt-submit', () => { // the allowed prompt keeps the turn from ending rejected. const adapter = new MockAdapter([textResponse('ran once')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/prompt-submit', async (_agent, content, _source, next): Promise => { const text = content.map(b => (b.type === 'text' ? b.text : '')).join('') @@ -233,7 +228,7 @@ describe('agent/prompt-submit', () => { it('a throwing prompt-submit listener ends the turn balanced (error), loop survives', async () => { const adapter = new MockAdapter([textResponse('after')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let threw = false ctx.on('agent/prompt-submit', async () => { @@ -266,7 +261,7 @@ describe('agent/session-start', () => { const sources: SessionStartSource[] = [] ctx.on('agent/session-start', (_agent, source) => void sources.push(source)) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // fires synchronously at create, before any turn expect(sources).toEqual(['startup']) expect(events(agent).some(e => e.type === 'turn/start')).toBe(false) @@ -285,7 +280,7 @@ describe('agent/session-start', () => { agent.inject([{ type: 'text', text: 'session preamble' }], { source: { kind: 'plugin', plugin: 'test' } }) }) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -303,8 +298,8 @@ describe('agent/session-start', () => { ctx.on('agent/session-start', () => { throw new Error('session-start hook broke') }) // create must not throw — the listener error is contained/logged - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) - expect(agent.id).toBe(AgentId('a1')) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + expect(agent.id).toBe(SessionId('a1')) // and the agent still runs send(agent, 'go') @@ -317,8 +312,8 @@ describe('agent/session-prefix', () => { it('dispatches to global and matching agent-scope listeners only', async () => { const adapter = new MockAdapter([textResponse('a done'), textResponse('b done')]) const ctx = await harness(adapter) - const agentA = ctx.agentLoop.create(AgentId('prefix-a'), { provider: 'mock', model: 'mock' }) - const agentB = ctx.agentLoop.create(AgentId('prefix-b'), { provider: 'mock', model: 'mock' }) + const agentA = ctx.agentLoop.create(SessionId('prefix-a'), { provider: 'mock', model: 'mock' }) + const agentB = ctx.agentLoop.create(SessionId('prefix-b'), { provider: 'mock', model: 'mock' }) const seen: string[] = [] ctx.on('agent/session-prefix', async (agent, _prefix, _signal, next) => { seen.push(`global:${agent.id}`) @@ -355,7 +350,7 @@ describe('agent/session-prefix', () => { name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const reminder: Message = { role: 'user', content: [{ type: 'text', text: 'catalog' }] } let composed = 0 @@ -388,7 +383,7 @@ describe('agent/session-prefix', () => { it('composes before the first pre-step and hands the prefix to the seam (pressure gates see the real value)', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const reminder: Message = { role: 'user', content: [{ type: 'text', text: 'opener' }] } const order: string[] = [] @@ -415,7 +410,7 @@ describe('agent/session-prefix', () => { it('the canonical prepend pattern composes contributions in registration order', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // Both listeners use the canonical `[mine, ...await next()]` prepend: the // waterfall unwinds innermost-first (the second listener's array is built @@ -437,7 +432,7 @@ describe('agent/session-prefix', () => { it('with no contributions the header omits messagePrefix and the request is the bare derivation', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // A listener that delegates without contributing — the canonical no-op. ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => next()) @@ -453,7 +448,7 @@ describe('agent/session-prefix', () => { it('the frozen seed rejects in-place mutation — a contribution is a returned extension', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let mutationError: unknown ctx.on('agent/session-prefix', async (_agent, prefix, _signal, next): Promise => { @@ -482,7 +477,7 @@ describe('agent/session-prefix', () => { name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const held: Message = { role: 'user', content: [{ type: 'text', text: 'v1' }] } ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise => [...await next(), held]) @@ -503,7 +498,7 @@ describe('agent/turn-continuation (ContinuationDecision)', () => { it('a continue decision with a reason records next-step steering in the same turn', async () => { const adapter = new MockAdapter([textResponse('step 1 no tools'), textResponse('step 2')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let forced = false ctx.on('agent/turn-continuation', async (_agent, _turn, _default, next): Promise => { @@ -535,7 +530,7 @@ describe('agent/turn-continuation (ContinuationDecision)', () => { name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/turn-continuation', async (): Promise => ({ action: 'stop' })) @@ -565,7 +560,7 @@ describe('tool additionalContexts buffering across a step', () => { name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // Each call attaches one context naming itself. ctx.on('tools/post-execute', async (exec, _result): Promise => @@ -613,7 +608,7 @@ describe('tool additionalContexts buffering across a step', () => { return [{ type: 'text', text: 'outer result' }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -640,7 +635,7 @@ describe('tools/pre-execute gate (native-plugin permission pattern, end-to-end t name: 'danger', description: 'danger', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('tools/pre-execute', async (exec, next): Promise => { if (exec.name === 'danger') return { kind: 'deny', reason: 'blocked dangerous tool' } @@ -702,7 +697,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'please echo hi') await waitForIdle(ctx, agent) @@ -725,7 +720,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) await ctx.plugin(NativeGuard) - const agent = ctx.agentLoop.create(AgentId('a2'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a2'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -744,7 +739,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se await fiber.dispose() // After disposal, a destructive prompt is NOT blocked (the listener is gone). - const agent = ctx.agentLoop.create(AgentId('a3'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a3'), { provider: 'mock', model: 'mock' }) send(agent, 'run rm -rf /') await waitForIdle(ctx, agent) // the prompt ran (not rejected) — proving the prompt-submit listener was disposed diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index c94347a70d..105156f70e 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -4,11 +4,15 @@ import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts' +function driverDone(agent: Agent): Promise { + return (agent as Agent & { done: Promise }).done +} + async function harness(adapter: MockAdapter, persona = '') { const ctx = new Context() await ctx.plugin(LlmService) @@ -27,7 +31,7 @@ async function harness(adapter: MockAdapter, persona = '') { * invoke this right after send(), when the loop hasn't woken yet (status is * still 'idle' synchronously), so polling the current status would lie. */ -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -38,7 +42,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } -function send(agent: ReactLoopAgent, text: string) { +function send(agent: Agent, text: string) { agent.send([{ type: 'text', text }]) } @@ -46,7 +50,7 @@ describe('agent loop', () => { it('runs a simple turn: queued message → model → idle, with ordered events', async () => { const adapter = new MockAdapter([textResponse('hello there')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // All boundaries — turn and step — are durable session events on the // session/event feed (no agent/* mirror). Record them in fire order to @@ -94,7 +98,7 @@ describe('agent loop', () => { return [{ type: 'text', text: `echo: ${args.text}` }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'use the tool') await waitForIdle(ctx, agent) @@ -133,7 +137,7 @@ describe('agent loop', () => { return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a.txt', oldText: null, newText: 'x' }] } } }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'use the tool') await waitForIdle(ctx, agent) @@ -157,7 +161,7 @@ describe('agent loop', () => { return [] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'hi') await waitForIdle(ctx, agent) @@ -171,13 +175,12 @@ describe('agent loop', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter, 'Working in {{cwd}}.') const handle = await ctx.agents.create({ - agentId: AgentId('a-cwd'), sessionId: SessionId('s-cwd'), meta: { cwd: '/work/space' }, agentOptions: { provider: 'mock', model: 'mock' }, }) - const agent = handle.agent as ReactLoopAgent + const agent = handle.agent send(agent, 'hi') await waitForIdle(ctx, agent) @@ -190,7 +193,7 @@ describe('agent loop', () => { const ctx = await harness(adapter, 'In {{cwd}}.') const errors: Error[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'hi') await waitForIdle(ctx, agent) @@ -232,7 +235,7 @@ describe('agent loop', () => { ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => { return { ...config, provider: 'mock', model: 'mock' } }) - const agent = ctx.agentLoop.create(AgentId('a-late-model'), {}) + const agent = ctx.agentLoop.create(SessionId('a-late-model'), {}) send(agent, 'hi') await waitForIdle(ctx, agent) @@ -258,7 +261,7 @@ describe('agent loop', () => { parameters: {}, execute: () => Promise.resolve({ content: [{ type: 'text' as const, text: 'apparent success' }], meta }), })) - const agent = ctx.agentLoop.create(AgentId('bad-meta-agent'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('bad-meta-agent'), { provider: 'mock', model: 'mock' }) send(agent, 'use the tool') await waitForIdle(ctx, agent) @@ -287,7 +290,7 @@ describe('agent loop', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) ctx.on('system-prompt/assemble', async () => ({ sections: [], tools: [], variables: {} })) - const agent = ctx.agentLoop.create(AgentId('a-no-system'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-no-system'), { provider: 'mock', model: 'mock' }) send(agent, 'hi') await waitForIdle(ctx, agent) @@ -299,7 +302,7 @@ describe('agent loop', () => { it('records raw chunks for replay as assistant/chunk session events', async () => { const adapter = new MockAdapter([textResponse('abc')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'hi') await waitForIdle(ctx, agent) @@ -323,7 +326,7 @@ describe('agent loop', () => { ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.tools.register(defineTool({ name: 'slow', description: '', @@ -355,7 +358,7 @@ describe('agent loop', () => { it('steering while idle behaves like send (starts a turn)', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.steer([{ type: 'text', text: 'hello' }]) await waitForIdle(ctx, agent) @@ -365,7 +368,7 @@ describe('agent loop', () => { it('inject() while idle wraps context in a one-shot turn, visible to the next request', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.inject([{ type: 'text', text: 'file changed: a.ts' }], { source: { kind: 'plugin', plugin: 'watcher' } }) // The idle inject records a self-contained turn (turn/start → context/message @@ -389,7 +392,7 @@ describe('agent loop', () => { it('inject() can persist raw structured context without the generic context envelope', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('raw-context'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('raw-context'), { provider: 'mock', model: 'mock' }) const text = 'Additional instructions from: pkg/AGENTS.md' const meta = { kind: 'workspace-instructions', @@ -418,7 +421,7 @@ describe('agent loop', () => { textResponse('done'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let visibleDuringTool = false const meta = { kind: 'deferred-test', version: 1 } ctx.tools.register(defineTool({ @@ -484,7 +487,7 @@ describe('agent loop', () => { textResponse('done'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('invalid-context'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('invalid-context'), { provider: 'mock', model: 'mock' }) ctx.tools.register(defineTool({ name: 'invalid-injector', description: 'attempts an invalid context injection', @@ -514,7 +517,7 @@ describe('agent loop', () => { textResponse('step 3'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let steps = 0 ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ }) @@ -540,7 +543,7 @@ describe('agent loop', () => { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/turn-continuation', async () => ({ action: 'stop' }) as const) @@ -555,7 +558,7 @@ describe('agent loop', () => { it('agent/request waterfall switches models by returning a replacement config; the switch is logged', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => { // The seed is frozen — config is not a mutable per-call knob; a switch @@ -588,7 +591,7 @@ describe('agent loop', () => { name: 'echo', description: 'echo', parameters: {}, async execute() { return [{ type: 'text', text: 'echoed' }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const fires: { turn: number; step: number; fullSystemPrompt: string }[] = [] ctx.on('agent/pre-step', (subject, turn, step, fullSystemPrompt) => { @@ -612,7 +615,7 @@ describe('agent loop', () => { // same step's request must include it. const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let injected = false ctx.on('agent/pre-step', (subject) => { @@ -646,7 +649,7 @@ describe('agent loop', () => { // closing, the turn records error, and the loop remains available. const adapter = new MockAdapter([textResponse('second turn ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let throwOnce = true ctx.on('agent/pre-step', () => { @@ -680,7 +683,7 @@ describe('agent loop', () => { it('cancel() mid-stream ends the turn with reason aborted', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -700,7 +703,7 @@ describe('agent loop', () => { // turn stops by default and ends max-tokens, not completed. const adapter = new MockAdapter([maxTokensResponse('truncat')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -723,7 +726,7 @@ describe('agent loop', () => { textResponse('second half'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let steps = 0 ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ }) @@ -754,7 +757,7 @@ describe('agent loop', () => { // stop. The per-turn reason must be independent — turn 2 ends completed. const adapter = new MockAdapter([maxTokensResponse('cut'), textResponse('clean')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -787,7 +790,7 @@ describe('agent loop', () => { return [{ type: 'text', text: 'should not run' }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -824,7 +827,7 @@ describe('agent loop', () => { parameters: { text: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'should not run' }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -849,7 +852,7 @@ describe('agent loop', () => { // a durable successful-call boundary for replay consumers. const adapter = new MockAdapter([[{ type: 'finish', reason: { kind: 'stop' } }]]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -886,7 +889,7 @@ describe('agent loop', () => { expect(message.content).toEqual([{ type: 'text', text: 'partial text' }]) return next() }) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -913,7 +916,7 @@ describe('agent loop', () => { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let threw = false // Post-commit session observers cannot control the loop. The tool call still // drives the second model request, and the turn completes normally. @@ -932,7 +935,7 @@ describe('agent loop', () => { it('chains queued messages into consecutive turns', async () => { const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const turns: number[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) }) @@ -957,7 +960,7 @@ describe('agent loop', () => { it('awaits session/flush at turn end (persistence checkpoint)', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let flushed = 0 let flushedBeforeIdle = false @@ -977,7 +980,7 @@ describe('agent loop', () => { it('errors from the model surface as agent/error and end the turn', async () => { const adapter = new MockAdapter([]) // script exhausted → throws const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const errors: Error[] = [] const reasons: TurnEndReason[] = [] @@ -1000,21 +1003,21 @@ describe('agent loop', () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('scoped'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) - expect(ctx.agents.get(AgentId('scoped'))).toBe(agent) + expect(ctx.agents.get(SessionId('scoped'))).toBe(agent) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') await fiber.dispose() - await agent.done + await driverDone(agent) expect(agent.status).toBe('disposed') - expect(ctx.agents.get(AgentId('scoped'))).toBeUndefined() + expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined() expect(() => { send(agent, 'too late') }).toThrow('disposed') }) @@ -1028,13 +1031,14 @@ describe('agent loop', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { - agents: [{ id: AgentId('config-agent'), provider: 'mock', model: 'mock' }], + agents: [{ id: SessionId('config-agent'), provider: 'mock', model: 'mock' }], }) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agents.get(AgentId('config-agent'))! as ReactLoopAgent + const agent = ctx.agents.list()[0]! expect(agent).toBeDefined() - expect(agent.id).toBe('config-agent') + expect(agent.id).toBe(agent.session.id) + expect(agent.id).toMatch(/^config-agent-session-/) expect(agent.options.model).toBe('mock') // the agent is alive: send triggers a turn @@ -1052,10 +1056,10 @@ describe('agent loop', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { - agents: [{ id: AgentId('config-agent'), provider: 'mock', model: 'mock', cwd: '/work/project' }], + agents: [{ id: SessionId('config-agent'), provider: 'mock', model: 'mock', cwd: '/work/project' }], }) - const agent = ctx.agents.get(AgentId('config-agent'))! as ReactLoopAgent + const agent = ctx.agents.list()[0]! expect(agent.session.header.cwd).toBe('/work/project') }) @@ -1073,7 +1077,7 @@ describe('agent loop', () => { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'run') await waitForIdle(ctx, agent) diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index 7a56e6e608..48c5d767c0 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -1,7 +1,12 @@ /** - * Deterministic property tests for inbox scheduling: every sent message logs - * once, turn numbers increase, and status follows idle→running→idle/disposed. - * Schedules advance on status events rather than wall-clock sleeps. + * Property-based tests for the agent loop's inbox/turn scheduling (the + * property-testing RFC). Deterministic by construction: schedules are driven + * through the `agent/status` settle signal (no wall-clock sleeps), so a flake + * is a finding, not timing noise. + * + * Invariants: every sent message appears exactly once in the log (none lost); + * turn numbers strictly increase; status transitions follow the legal machine + * idle→running→idle (and →disposed at teardown). */ import { describe, expect, it } from 'vitest' @@ -9,12 +14,12 @@ import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { LlmAdapter } from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import fc from 'fast-check' /** A never-exhausting adapter: every model call returns the same short reply. */ @@ -44,7 +49,7 @@ async function harness() { } /** Resolve on the agent's next transition to idle (event-based, not polled). */ -function nextIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function nextIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -57,7 +62,7 @@ function nextIdle(ctx: Context, agent: ReactLoopAgent): Promise { /** Record every status transition for the legal-machine assertion. Returns * the seen list plus a disposer for the listener (per the registry convention). */ -function recordStatus(ctx: Context, agent: ReactLoopAgent): { seen: string[]; dispose: () => void } { +function recordStatus(ctx: Context, agent: Agent): { seen: string[]; dispose: () => void } { const seen: string[] = [] const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent) seen.push(status) @@ -65,13 +70,13 @@ function recordStatus(ctx: Context, agent: ReactLoopAgent): { seen: string[]; di return { seen, dispose } } -function userMessageTexts(agent: ReactLoopAgent): string[] { +function userMessageTexts(agent: Agent): string[] { return agent.session.events .filter(e => e.type === 'user/message') .map(e => (e.data as { content: { type: string; text?: string }[] }).content.map(b => b.text ?? '').join('')) } -function turnNumbers(agent: ReactLoopAgent): number[] { +function turnNumbers(agent: Agent): number[] { return agent.session.events .filter(e => e.type === 'turn/start') .map(e => (e.data as { turn: number }).turn) @@ -92,7 +97,7 @@ describe('agent loop scheduling properties', () => { async (texts) => { const ctx = await harness() try { - const agent = ctx.agentLoop.create(AgentId('a'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' }) const { seen: trace } = recordStatus(ctx, agent) const idle = nextIdle(ctx, agent) // Send all in one synchronous tick: they queue before the loop wakes. @@ -117,7 +122,7 @@ describe('agent loop scheduling properties', () => { async (texts) => { const ctx = await harness() try { - const agent = ctx.agentLoop.create(AgentId('a'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' }) for (const text of texts) { const idle = nextIdle(ctx, agent) agent.send([{ type: 'text', text }]) @@ -142,7 +147,7 @@ describe('agent loop scheduling properties', () => { async (steps) => { const ctx = await harness() try { - const agent = ctx.agentLoop.create(AgentId('a'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' }) // Capture before each send; the last waiter covers the final turn, and // awaiting an already-settled earlier waiter is harmless. let lastIdle: Promise | undefined diff --git a/packages/core/agent-loop/tests/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts index 2ad6b6a4e7..31c4865c61 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -1,10 +1,10 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -71,7 +71,7 @@ function waitForIdle(context: Context, agent: Agent): Promise { describe.skipIf(!process.env.DEEPSEEK_API_KEY)('log-derived request cache hits (real API)', () => { it('every request after the first hits the provider prefix cache', async () => { ctx = await loopHarness() - const agent = ctx.agentLoop.create(AgentId('cache-e2e'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('cache-e2e'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) // Turn 1: forces a tool call → at least two steps (two model requests). agent.send([{ type: 'text', text: 'Look up the key "deploy-color" with the lookup tool and tell me the value.' }]) diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 8fafd4ca6a..20000e9057 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -12,9 +12,9 @@ import type { GenerateOptions } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' async function harness(adapter: MockAdapter, persona = 'stable base') { @@ -30,7 +30,7 @@ async function harness(adapter: MockAdapter, persona = 'stable base') { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -41,7 +41,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } -function send(agent: ReactLoopAgent, text: string) { +function send(agent: Agent, text: string) { agent.send([{ type: 'text', text }]) } @@ -73,7 +73,7 @@ describe('request stability across the loop', () => { ]) const ctx = await harness(adapter) registerEcho(ctx) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -94,7 +94,7 @@ describe('request stability across the loop', () => { it('a later turn append-extends the previous turn (one conversation, one log)', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -108,7 +108,7 @@ describe('request stability across the loop', () => { it('a compaction replace rewrites the resend, and the log explains it', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -141,7 +141,7 @@ describe('request stability across the loop', () => { it('a real system-prompt change is a full changed-header snapshot; a stable prompt logs nothing', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two'), textResponse('three')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -165,7 +165,7 @@ describe('request stability across the loop', () => { it('an inject() during the agent/request waterfall joins the NEXT request (the step/start boundary)', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let injected = false ctx.on('agent/request', async (_agent, _turn, _step, _config, next) => { @@ -193,7 +193,7 @@ describe('request stability across the loop', () => { it('a mutation attempt on the frozen request content throws into the step (loud, not silent)', async () => { const adapter = new MockAdapter([textResponse('one')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const errors: Error[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) @@ -214,7 +214,7 @@ describe('request stability across the loop', () => { it('a fresh loop instance over a seeded log anchors with a resume snapshot and stays cache-aligned', async () => { const adapter = new MockAdapter([textResponse('one')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('gen1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('gen1'), { provider: 'mock', model: 'mock' }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -223,12 +223,11 @@ describe('request stability across the loop', () => { const adapter2 = new MockAdapter([textResponse('two')]) const ctx2 = await harness(adapter2) const handle = await ctx2.agents.create({ - agentId: AgentId('gen2'), sessionId: SessionId('gen2-session'), seed: [...agent.session.events], agentOptions: { provider: 'mock', model: 'mock' }, }) - const agent2 = handle.agent as ReactLoopAgent + const agent2 = handle.agent send(agent2, 'second') await waitForIdle(ctx2, agent2) @@ -243,7 +242,7 @@ describe('request stability across the loop', () => { it('a delegating listener cannot mutate the seed through next() — the fold stays log-true', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/request', async (_agent, _turn, _step, _config, next) => { const config = await next() @@ -277,7 +276,7 @@ describe('request stability across the loop', () => { ]) const ctx = await harness(adapter) registerEcho(ctx) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'go') await waitForIdle(ctx, agent) diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index a46777cddd..bd00eb8bdd 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -8,10 +8,11 @@ import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' + import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' const dirs: string[] = [] @@ -52,7 +53,7 @@ async function persistSession(sessionId: SessionId): Promise { return root } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { dispose(); resolve() } @@ -85,11 +86,10 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { ctx.on('session/created', () => throwUnknown(failure)) await expect(ctx.agents.resume({ - agentId: AgentId('unknown-resume-failure'), resumeSessionId: sessionId, })).rejects.toBe(failure) - expect(ctx.agents.get(AgentId('unknown-resume-failure'))).toBeUndefined() + expect(ctx.agents.get(SessionId('unknown-resume-failure'))).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() await ctx.fiber.dispose() }) @@ -97,27 +97,26 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { it('createAgent uses the caller-supplied sessionId (not ${id}-session)', async () => { const adapter = new MockAdapter([textResponse('hi')]) const { ctx } = await persistentHarness(adapter) - const { agent } = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('custom-session'), meta: { cwd: '/w' } }) + const { agent } = await ctx.agents.create({ sessionId: SessionId('custom-session'), meta: { cwd: '/w' } }) expect(agent.session.id).toBe('custom-session') expect(agent.session.header.cwd).toBe('/w') await ctx.fiber.dispose() }) - it('createAgent rejects a duplicate agent id BEFORE creating the session (no orphan)', async () => { + it('createAgent rejects a duplicate identity without orphaning a session', async () => { const adapter = new MockAdapter([textResponse('hi')]) const { ctx } = await persistentHarness(adapter) - await ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-a') }) - // A second create with the SAME agent id but a fresh session id must reject - // up front — and must NOT leave an orphaned 'sess-b' session behind. - await expect(ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-b') })).rejects.toThrow(/already registered/) - expect(ctx.sessions.get(SessionId('sess-b'))).toBeUndefined() + const sessionId = SessionId('sess-a') + await ctx.agents.create({ sessionId }) + await expect(ctx.agents.create({ sessionId })).rejects.toThrow(/already exists/) + expect(ctx.sessions.list()).toHaveLength(1) await ctx.fiber.dispose() }) it('createAgent works without meta (no cwd)', async () => { const adapter = new MockAdapter([textResponse('hi')]) const { ctx } = await persistentHarness(adapter) - const { agent } = await ctx.agents.create({ agentId: AgentId('a-nometa'), sessionId: SessionId('nometa-session') }) + const { agent } = await ctx.agents.create({ sessionId: SessionId('nometa-session') }) expect(agent.session.id).toBe('nometa-session') expect(agent.session.header.cwd).toBeUndefined() await ctx.fiber.dispose() @@ -127,7 +126,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // Lifecycle 1: create a no-cwd session and run a turn. const adapter1 = new MockAdapter([textResponse('a')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('nocwd-sess') })).agent a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -144,7 +143,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) - const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('nocwd-sess') })).agent expect(a2.session.header.cwd).toBeUndefined() await ctx2.fiber.dispose() }) @@ -155,7 +154,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { const { ctx: ctx1, root } = await persistentHarness(adapter1) const sources1: string[] = [] ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source)) - const a1 = (await ctx1.agents.create({ agentId: AgentId('s'), sessionId: SessionId('start-sess') })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent expect(sources1).toEqual(['startup']) a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) @@ -175,7 +174,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { ctx2.llm.registerAdapter(['mock'], adapter2) const sources2: string[] = [] ctx2.on('agent/session-start', (_agent, source) => void sources2.push(source)) - await ctx2.agents.resume({ agentId: AgentId('s'), resumeSessionId: SessionId('start-sess') }) + await ctx2.agents.resume({ resumeSessionId: SessionId('start-sess') }) expect(sources2).toEqual(['resume']) await ctx2.fiber.dispose() }) @@ -190,7 +189,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { ctx.on('session/created', (session) => { expect(ctx.sessions.get(session.id)).toBe(session) - expect(ctx.agents.get(AgentId('resumed-atomic'))?.session).toBe(session) + expect(ctx.agents.get(sessionId)?.session).toBe(session) order.push('session/created') }) ctx.on('agent/created', (agent) => { @@ -203,11 +202,10 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { }) const resuming = ctx.agents.resume({ - agentId: AgentId('resumed-atomic'), resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' }, setup: async (agentCtx) => { - expect(agentCtx.agent?.id).toBe(AgentId('resumed-atomic')) + expect(agentCtx.agent?.id).toBe(sessionId) expect(agentCtx.agent?.session.events).toHaveLength(2) agentCtx.on('session/created', () => void order.push('setup-listener:session/created')) agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created')) @@ -219,7 +217,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { }) await setupStarted.promise - expect(ctx.agents.get(AgentId('resumed-atomic'))).toBeUndefined() + expect(ctx.agents.get(sessionId)).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() expect(order).toEqual(['setup:start']) @@ -240,17 +238,15 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { it('successful resume disposal retires its caller-owned transaction effects', async () => { const sessionId = SessionId('resume-retired-effects-s') - const agentId = AgentId('resume-retired-effects') const root = await persistSession(sessionId) const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) const handle = await ctx.agents.resume({ - agentId, resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' }, }) const transactionLabels = [ - `agentLoop.owner(${agentId})`, - `agentLoop.lifecycle(${agentId})`, + `agentLoop.owner(${sessionId})`, + `agentLoop.lifecycle(${sessionId})`, ] expect(ctx.fiber.getEffects().map(effect => effect.label)).toEqual(expect.arrayContaining(transactionLabels)) @@ -259,7 +255,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx.fiber.dispose() }) - it('resume setup rejection publishes nothing, unwinds, and releases both identities', async () => { + it('resume setup rejection publishes nothing, unwinds, and releases the identity', async () => { const sessionId = SessionId('resume-setup-reject') const root = await persistSession(sessionId) const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) @@ -269,7 +265,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { ctx.on('agent/session-start', () => void published.push('agent/session-start')) await expect(ctx.agents.resume({ - agentId: AgentId('resume-reject'), resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' }, setup: async () => { @@ -279,10 +274,9 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { })).rejects.toThrow('resume setup failed') expect(published).toEqual([]) - expect(ctx.agents.get(AgentId('resume-reject'))).toBeUndefined() + expect(ctx.agents.get(sessionId)).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() const retry = await ctx.agents.resume({ - agentId: AgentId('resume-reject'), resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' }, }) @@ -303,7 +297,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { let resuming!: ReturnType const owner = await ctx.plugin(Object.assign((inner: Context) => { resuming = inner.agents.resume({ - agentId: AgentId('resume-owner-race'), resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' }, setup: async () => { @@ -317,7 +310,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await owner.dispose() await expect(resuming).rejects.toThrow(/owner disposed during setup/) expect(published).toEqual([]) - expect(ctx.agents.get(AgentId('resume-owner-race'))).toBeUndefined() + expect(ctx.agents.get(sessionId)).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() gate.resolve(undefined) @@ -326,9 +319,8 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx.fiber.dispose() }) - it('owner unload aborts a never-settling persistence load, releases identities, and blocks late publication', async () => { + it('owner unload aborts a never-settling persistence load, releases the identity, and blocks late publication', async () => { const sessionId = SessionId('resume-load-owner-unload') - const agentId = AgentId('resume-load-race') const root = await persistSession(sessionId) const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) const snapshot = await ctx.sessionPersistence.load(sessionId) @@ -352,19 +344,19 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { let resuming!: ReturnType const owner = await ctx.plugin(Object.assign((inner: Context) => { - resuming = inner.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } }) + resuming = inner.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } }) }, { inject: ['agents'] })) await loadStarted.promise const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during setup/) await promptly(owner.dispose()) expect(published).toEqual([]) - expect(ctx.agents.get(agentId)).toBeUndefined() + expect(ctx.agents.get(sessionId)).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() // owner.dispose() awaited transaction settlement, so the same identities // can be reused before awaiting the public rejection. - const retry = await promptly(ctx.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } })) + const retry = await promptly(ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } })) await rejection expect(loads).toBe(2) expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start']) @@ -374,7 +366,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { lateLoad.resolve(structuredClone(snapshot)) await Promise.resolve() await Promise.resolve() - expect(ctx.agents.get(agentId)).toBe(retry.agent) + expect(ctx.agents.get(sessionId)).toBe(retry.agent) expect(ctx.sessions.get(sessionId)).toBe(retry.agent.session) expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start']) @@ -384,7 +376,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { it('AgentLoop unload aborts persistence load and awaits wrapper settlement', async () => { const sessionId = SessionId('resume-load-factory-unload') - const agentId = AgentId('resume-load-factory-race') const root = await persistSession(sessionId) const ctx = new Context() await ctx.plugin(LlmService) @@ -409,14 +400,14 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { ctx.on('session/created', () => void published.push('session/created')) ctx.on('agent/created', () => void published.push('agent/created')) - const resuming = ctx.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } }) + const resuming = ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } }) await loadStarted.promise const rejection = expect(promptly(resuming)).rejects.toThrow(/agent loop is not active/) await promptly(loopFiber.dispose()) await rejection expect(published).toEqual([]) - expect(ctx.agents.get(agentId)).toBeUndefined() + expect(ctx.agents.get(sessionId)).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() lateLoad.resolve(structuredClone(snapshot)) await Promise.resolve() @@ -458,7 +449,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) - const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('forked-sess') })).agent as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('forked-sess') })).agent expect(a2.session.header.parentSession).toBe('parent-sess') expect(a2.session.header.cwd).toBe('/w') expect(a2.session.header.seedLength).toBe(seed.length) @@ -470,7 +461,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // clean disposal follows, so disk presence proves its own checkpoint ran. const adapter1 = new MockAdapter([textResponse('answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } }) @@ -493,7 +484,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // survive persistence and resume. const adapter1 = new MockAdapter([textResponse('answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } }) @@ -512,7 +503,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) - const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('inject-sess') })).agent as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('inject-sess') })).agent const flat = JSON.stringify(a2.session.deriveMessages()) expect(flat).toContain('background task 42 finished') await ctx2.fiber.dispose() @@ -522,7 +513,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // Lifecycle 1: run one full turn, persisting it. const adapter1 = new MockAdapter([textResponse('first answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = (await ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) const events1 = [...a1.session.events] @@ -543,7 +534,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) - const a2 = (await ctx2.agents.resume({ agentId: AgentId('main'), resumeSessionId: SessionId('sess-resume') })).agent as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('sess-resume') })).agent // The resumed session carries the prior history… expect(a2.session.id).toBe('sess-resume') expect(a2.session.events.length).toBe(events1.length) @@ -572,7 +563,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) - await expect(ctx.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('nope') })) + await expect(ctx.agents.resume({ resumeSessionId: SessionId('nope') })) .rejects.toThrow(/session persistence is not configured/) await ctx.fiber.dispose() }) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 35263d9456..aba59c8a68 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -4,11 +4,12 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId, agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' + import type { Agent } from '@deepseek-ai/dsh-agent' import { scopeOf } from '@deepseek-ai/dsh-scope' import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -29,7 +30,7 @@ async function harness(adapter: MockAdapter = new MockAdapter([textResponse('ok' return (await harnessWithLoop(adapter)).ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -59,33 +60,31 @@ function disposeCurrentLifecycle(ownerCtx: Context): void { } describe('agent scope lifecycle', () => { - it('rejects an already-aborted creation signal before publishing either identity', async () => { + it('rejects an already-aborted creation signal before publishing either object', async () => { const ctx = await harness() const reason = new Error('cancelled before creation') const controller = new AbortController() controller.abort(reason) await expect(ctx.agents.create({ - agentId: AgentId('pre-aborted'), sessionId: SessionId('pre-aborted-s'), signal: controller.signal, })).rejects.toBe(reason) - expect(ctx.agents.get(AgentId('pre-aborted'))).toBeUndefined() + expect(ctx.agents.get(SessionId('pre-aborted-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('pre-aborted-s'))).toBeUndefined() const valueController = new AbortController() valueController.abort('plain cancellation reason') await expect(ctx.agents.create({ - agentId: AgentId('pre-aborted-value'), sessionId: SessionId('pre-aborted-value-s'), signal: valueController.signal, })).rejects.toMatchObject({ - message: 'agent "pre-aborted-value" creation aborted', + message: 'agent "pre-aborted-value-s" creation aborted', cause: 'plain cancellation reason', }) - expect(ctx.agents.get(AgentId('pre-aborted-value'))).toBeUndefined() + expect(ctx.agents.get(SessionId('pre-aborted-value-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('pre-aborted-value-s'))).toBeUndefined() await ctx.fiber.dispose() }) @@ -102,12 +101,11 @@ describe('agent scope lifecycle', () => { }) await expect(ctx.agents.create({ - agentId: AgentId('prepare-abort'), sessionId: SessionId('prepare-abort-s'), signal: controller.signal, })).rejects.toBe(reason) - expect(ctx.agents.get(AgentId('prepare-abort'))).toBeUndefined() + expect(ctx.agents.get(SessionId('prepare-abort-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('prepare-abort-s'))).toBeUndefined() await ctx.fiber.dispose() }) @@ -126,7 +124,7 @@ describe('agent scope lifecycle', () => { thrown = createFailure let createCaught: unknown try { - ctx.agentLoop.create(AgentId('unknown-create')) + ctx.agentLoop.create(SessionId('unknown-create')) } catch (error: unknown) { createCaught = error } @@ -135,28 +133,45 @@ describe('agent scope lifecycle', () => { const ownedFailure = { source: 'createAgent' } thrown = ownedFailure await expect(ctx.agents.create({ - agentId: AgentId('unknown-owned-create'), sessionId: SessionId('unknown-owned-create-s'), })).rejects.toBe(ownedFailure) - expect(ctx.agents.get(AgentId('unknown-create'))).toBeUndefined() - expect(ctx.agents.get(AgentId('unknown-owned-create'))).toBeUndefined() + expect(ctx.agents.get(SessionId('unknown-create'))).toBeUndefined() + expect(ctx.agents.get(SessionId('unknown-owned-create-s'))).toBeUndefined() await ctx.fiber.dispose() }) it('wires agent.ctx: tagged with the agent, DX field set, ctx.agent safe elsewhere', async () => { const ctx = await harness() - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) expect(scopeOf(agent.ctx)).toBe(agent) expect(agent.ctx.agent).toBe(agent) // The root accessor default: a plain context answers undefined, not a throw. expect(ctx.agent).toBeUndefined() - await ctx.agents.get(AgentId('a1'))?.whenIdle() + await ctx.agents.get(SessionId('a1'))?.whenIdle() + }) + + it('records agents created through an agent context as non-root runtime children', async () => { + const ctx = await harness() + const root = await ctx.agents.create({ + sessionId: SessionId('runtime-root'), + agentOptions: { model: 'mock' }, + }) + const child = await root.agent.ctx.agents.create({ + sessionId: SessionId('runtime-child'), + agentOptions: { model: 'mock' }, + }) + + expect(ctx.agents.list()).toEqual([root.agent, child.agent]) + expect(ctx.agents.roots()).toEqual([root.agent]) + + await child.dispose() + await root.dispose() }) it('scoped registrations live in the agent world and die with the agent', async () => { const ctx = await harness() - const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { provider: 'mock', model: 'mock' } }) + const handle = await ctx.agents.create({ sessionId: SessionId('s1'), agentOptions: { provider: 'mock', model: 'mock' } }) const { agent } = handle agent.ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You run tests.' }) agent.ctx.tools.register({ @@ -181,8 +196,8 @@ describe('agent scope lifecycle', () => { it('agent.ctx listeners hear only their own agent (scoped dispatch end to end)', async () => { const ctx = await harness(new MockAdapter([textResponse('one'), textResponse('two')])) - const a = ctx.agentLoop.create(AgentId('a'), { provider: 'mock', model: 'mock' }) - const b = ctx.agentLoop.create(AgentId('b'), { provider: 'mock', model: 'mock' }) + const a = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' }) + const b = ctx.agentLoop.create(SessionId('b'), { provider: 'mock', model: 'mock' }) const heard: string[] = [] a.ctx.on('agent/status', (subject, status) => void heard.push(`a-sees:${subject.id}:${status}`)) @@ -212,7 +227,6 @@ describe('agent scope lifecycle', () => { }) const handle = await ctx.agents.create({ - agentId: AgentId('child'), sessionId: SessionId('child-s'), agentOptions: { provider: 'mock', model: 'mock' }, setup: async (agentCtx) => { @@ -226,14 +240,14 @@ describe('agent scope lifecycle', () => { await handle.dispose() }) - it('keeps both identities unpublished until async setup completes, then announces in order', async () => { + it('keeps both objects unpublished until async setup completes, then announces in order', async () => { const ctx = await harness() const gate = Promise.withResolvers() const setupStarted = Promise.withResolvers() const order: string[] = [] ctx.on('session/created', (session) => { expect(ctx.sessions.get(session.id)).toBe(session) - expect(ctx.agents.get(AgentId('atomic'))?.session).toBe(session) + expect(ctx.agents.get(session.id)?.session).toBe(session) order.push('session/created') }) ctx.on('agent/created', () => void order.push('agent/created')) @@ -241,11 +255,10 @@ describe('agent scope lifecycle', () => { const acceptedOptions = { provider: 'mock', model: 'mock' } const creating = ctx.agents.create({ - agentId: AgentId('atomic'), - sessionId: SessionId('atomic-s'), + sessionId: SessionId('atomic'), agentOptions: acceptedOptions, setup: async (agentCtx) => { - expect(agentCtx.agent?.id).toBe(AgentId('atomic')) + expect(agentCtx.agent?.id).toBe(SessionId('atomic')) agentCtx.on('session/created', () => void order.push('setup-listener:session/created')) agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created')) order.push('setup:start') @@ -255,8 +268,8 @@ describe('agent scope lifecycle', () => { }, }) await setupStarted.promise - expect(ctx.agents.get(AgentId('atomic'))).toBeUndefined() - expect(ctx.sessions.get(SessionId('atomic-s'))).toBeUndefined() + expect(ctx.agents.get(SessionId('atomic'))).toBeUndefined() + expect(ctx.sessions.get(SessionId('atomic'))).toBeUndefined() expect(order).toEqual(['setup:start']) gate.resolve(undefined) const handle = await creating @@ -283,16 +296,14 @@ describe('agent scope lifecycle', () => { if (started === 2) bothStarted.resolve(undefined) await gate.promise } - const agentId = AgentId('concurrent-final-enter') + const sessionId = SessionId('concurrent-final-enter') const first = ctx.agents.create({ - agentId, - sessionId: SessionId('concurrent-final-enter-a'), + sessionId, agentOptions: { provider: 'mock', model: 'mock' }, setup, }) const second = ctx.agents.create({ - agentId, - sessionId: SessionId('concurrent-final-enter-b'), + sessionId, agentOptions: { provider: 'mock', model: 'mock' }, setup, }) @@ -306,7 +317,7 @@ describe('agent scope lifecycle', () => { const rejected = outcomes.filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected') expect(fulfilled).toHaveLength(1) expect(rejected).toHaveLength(1) - expect(String(rejected[0]!.reason)).toMatch(/already registered/) + expect(String(rejected[0]!.reason)).toMatch(/already exists/) expect(ctx.agents.list()).toEqual([fulfilled[0]!.value.agent]) expect(ctx.sessions.list()).toEqual([fulfilled[0]!.value.agent.session]) @@ -320,7 +331,6 @@ describe('agent scope lifecycle', () => { const pendingController = new AbortController() const setupStarted = Promise.withResolvers() const pending = ctx.agents.create({ - agentId: AgentId('signal-pending'), sessionId: SessionId('signal-pending-s'), agentOptions: { provider: 'mock', model: 'mock' }, signal: pendingController.signal, @@ -332,12 +342,11 @@ describe('agent scope lifecycle', () => { await setupStarted.promise pendingController.abort(new Error('cancel pending creation')) await expect(pending).rejects.toThrow('cancel pending creation') - expect(ctx.agents.get(AgentId('signal-pending'))).toBeUndefined() + expect(ctx.agents.get(SessionId('signal-pending-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('signal-pending-s'))).toBeUndefined() const liveController = new AbortController() const live = await ctx.agents.create({ - agentId: AgentId('signal-live'), sessionId: SessionId('signal-live-s'), agentOptions: { provider: 'mock', model: 'mock' }, signal: liveController.signal, @@ -360,7 +369,6 @@ describe('agent scope lifecycle', () => { let creating!: ReturnType const owner = await ctx.plugin(Object.assign((inner: Context) => { creating = inner.agents.create({ - agentId: AgentId('owner-race'), sessionId: SessionId('owner-race-s'), agentOptions: { provider: 'mock', model: 'mock' }, setup: async () => { @@ -374,7 +382,7 @@ describe('agent scope lifecycle', () => { await owner.dispose() await expect(creating).rejects.toThrow(/owner disposed during setup/) expect(published).toEqual([]) - expect(ctx.agents.get(AgentId('owner-race'))).toBeUndefined() + expect(ctx.agents.get(SessionId('owner-race-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('owner-race-s'))).toBeUndefined() // Let the losing callback settle; Promise.race already observes it. gate.resolve(undefined) @@ -388,7 +396,6 @@ describe('agent scope lifecycle', () => { let creating2!: ReturnType const owner2 = await ctx.plugin(Object.assign((inner: Context) => { creating2 = inner.agents.create({ - agentId: AgentId('owner-race-2'), sessionId: SessionId('owner-race-s-2'), agentOptions: { provider: 'mock', model: 'mock' }, setup: async () => { @@ -402,7 +409,7 @@ describe('agent scope lifecycle', () => { const unload2 = owner2.dispose() await expect(creating2).rejects.toThrow(/owner disposed during setup/) await unload2 - expect(ctx.agents.get(AgentId('owner-race-2'))).toBeUndefined() + expect(ctx.agents.get(SessionId('owner-race-s-2'))).toBeUndefined() expect(ctx.sessions.get(SessionId('owner-race-s-2'))).toBeUndefined() }) @@ -415,7 +422,6 @@ describe('agent scope lifecycle', () => { ctx.on('agent/created', () => void published.push('agent/created')) const creating = ctx.agents.create({ - agentId: AgentId('factory-setup-race'), sessionId: SessionId('factory-setup-race-s'), agentOptions: { provider: 'mock', model: 'mock' }, setup: async () => { @@ -428,7 +434,7 @@ describe('agent scope lifecycle', () => { await loopFiber.dispose() await expect(creating).rejects.toThrow(/agent loop is not active/) expect(published).toEqual([]) - expect(ctx.agents.get(AgentId('factory-setup-race'))).toBeUndefined() + expect(ctx.agents.get(SessionId('factory-setup-race-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('factory-setup-race-s'))).toBeUndefined() gate.resolve(undefined) @@ -446,7 +452,6 @@ describe('agent scope lifecycle', () => { }) const creating = ctx.agents.create({ - agentId: AgentId('factory-scope-race'), sessionId: SessionId('factory-scope-race-s'), agentOptions: { provider: 'mock', model: 'mock' }, setup: () => { setupCalls += 1 }, @@ -454,7 +459,7 @@ describe('agent scope lifecycle', () => { await expect(creating).rejects.toThrow(/agent loop is not active/) await loopFiber.dispose() expect(setupCalls).toBe(0) - expect(ctx.agents.get(AgentId('factory-scope-race'))).toBeUndefined() + expect(ctx.agents.get(SessionId('factory-scope-race-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('factory-scope-race-s'))).toBeUndefined() await ctx.fiber.dispose() @@ -481,7 +486,6 @@ describe('agent scope lifecycle', () => { const owner = ctx.plugin(Object.assign((inner: Context) => { ownerFiber = inner.fiber creating = inner.agents.create({ - agentId: AgentId('caller-scope-race'), sessionId: SessionId('caller-scope-race-s'), agentOptions: { provider: 'mock', model: 'mock' }, }) @@ -497,7 +501,7 @@ describe('agent scope lifecycle', () => { await ownerDisposal await owner expect(scopeFiber?.uid).toBeNull() - expect(ctx.agents.get(AgentId('caller-scope-race'))).toBeUndefined() + expect(ctx.agents.get(SessionId('caller-scope-race-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('caller-scope-race-s'))).toBeUndefined() await owner.dispose() await ctx.fiber.dispose() @@ -513,17 +517,17 @@ describe('agent scope lifecycle', () => { void loopFiber.dispose() }) - expect(() => ctx.agentLoop.create(AgentId('config-scope-race'), { provider: 'mock', model: 'mock' })) + expect(() => ctx.agentLoop.create(SessionId('config-scope-race'), { provider: 'mock', model: 'mock' })) .toThrow(/agent loop is not active/) await loopFiber.dispose() - expect(ctx.agents.get(AgentId('config-scope-race'))).toBeUndefined() + expect(ctx.agents.get(SessionId('config-scope-race'))).toBeUndefined() expect(ctx.sessions.list()).toHaveLength(sessionsBefore) await ctx.fiber.dispose() }) it('synchronous create leaves no lifecycle state when session preparation fails', async () => { const ctx = await harness() - const id = AgentId('config-prepare-failure') + const id = SessionId('config-prepare-failure') expect(() => ctx.agentLoop.create(id, { provider: 'mock', model: 'mock' }, { cwd: 'relative' })) .toThrow(/absolute path/) @@ -544,12 +548,11 @@ describe('agent scope lifecycle', () => { }) await expect(ctx.agents.create({ - agentId: AgentId('factory-scope-throw'), sessionId: SessionId('factory-scope-throw-s'), agentOptions: { provider: 'mock', model: 'mock' }, })).rejects.toThrow('scope preparation failed') await loopFiber.dispose() - expect(ctx.agents.get(AgentId('factory-scope-throw'))).toBeUndefined() + expect(ctx.agents.get(SessionId('factory-scope-throw-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('factory-scope-throw-s'))).toBeUndefined() await ctx.fiber.dispose() @@ -558,23 +561,21 @@ describe('agent scope lifecycle', () => { it('AgentLoop unload is a structural co-owner of every live programmatic agent', async () => { const { ctx, loopFiber } = await harnessWithLoop() const loop = ctx.agentLoop - const agentId = AgentId('factory-live') + const sessionId = SessionId('factory-live') const handle = await ctx.agents.create({ - agentId, sessionId: SessionId('factory-live-s'), agentOptions: { provider: 'mock', model: 'mock' }, }) await loopFiber.dispose() expect(handle.agent.status).toBe('disposed') - expect(ctx.agents.get(agentId)).toBeUndefined() - expect(ctx.sessions.get(SessionId('factory-live-s'))).toBeUndefined() - expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${agentId})`)).toEqual([]) + expect(ctx.agents.get(sessionId)).toBeUndefined() + expect(ctx.sessions.get(sessionId)).toBeUndefined() + expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${sessionId})`)).toEqual([]) // The consumer handle shares the provider's completed quiescence boundary. await handle.dispose() await expect(loop.createAgent(ctx, { - agentId: AgentId('factory-inactive'), sessionId: SessionId('factory-inactive-s'), })).rejects.toThrow('agent loop is not active') await ctx.fiber.dispose() @@ -585,7 +586,6 @@ describe('agent scope lifecycle', () => { let creating!: ReturnType const owner = await ctx.plugin(Object.assign((inner: Context) => { creating = inner.agents.create({ - agentId: AgentId('dependency-origin'), sessionId: SessionId('dependency-origin-s'), agentOptions: { provider: 'mock', model: 'mock' }, setup: (agentCtx) => { @@ -625,7 +625,7 @@ describe('agent scope lifecycle', () => { }) ctx.on('session/created', (session) => { if (session.id !== SessionId('session-created-barrier-s')) return - const agent = ctx.agents.get(AgentId('session-created-barrier'))! + const agent = ctx.agents.get(SessionId('session-created-barrier-s'))! expect(ctx.sessions.get(session.id)).toBe(session) expect(agent.session).toBe(session) agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') }) @@ -640,7 +640,6 @@ describe('agent scope lifecycle', () => { const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner creating = inner.agents.create({ - agentId: AgentId('session-created-barrier'), sessionId: SessionId('session-created-barrier-s'), agentOptions: { provider: 'mock', model: 'mock' }, }) @@ -654,7 +653,7 @@ describe('agent scope lifecycle', () => { 'session-disposed', 'scope-disposed', ]) - expect(ctx.agents.get(AgentId('session-created-barrier'))).toBeUndefined() + expect(ctx.agents.get(SessionId('session-created-barrier-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('session-created-barrier-s'))).toBeUndefined() await ctx.fiber.dispose() }) @@ -668,19 +667,19 @@ describe('agent scope lifecycle', () => { if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-created') }) ctx.on('agent/created', (agent) => { - if (agent.id !== AgentId('agent-created-barrier')) return + if (agent.id !== SessionId('agent-created-barrier-s')) return lifecycle.push('agent-created:dispose') disposeCurrentLifecycle(ownerCtx) }) ctx.on('agent/created', (agent) => { - if (agent.id !== AgentId('agent-created-barrier')) return + if (agent.id !== SessionId('agent-created-barrier-s')) return expect(ctx.agents.get(agent.id)).toBe(agent) expect(ctx.sessions.get(agent.session.id)).toBe(agent.session) agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') }) lifecycle.push('agent-created:observer') }) ctx.on('agent/disposed', (agent) => { - if (agent.id === AgentId('agent-created-barrier')) lifecycle.push('agent-disposed') + if (agent.id === SessionId('agent-created-barrier-s')) lifecycle.push('agent-disposed') }) ctx.on('session/disposed', (session) => { if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-disposed') @@ -689,7 +688,6 @@ describe('agent scope lifecycle', () => { const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner creating = inner.agents.create({ - agentId: AgentId('agent-created-barrier'), sessionId: SessionId('agent-created-barrier-s'), agentOptions: { provider: 'mock', model: 'mock' }, }) @@ -705,7 +703,7 @@ describe('agent scope lifecycle', () => { 'session-disposed', 'scope-disposed', ]) - expect(ctx.agents.get(AgentId('agent-created-barrier'))).toBeUndefined() + expect(ctx.agents.get(SessionId('agent-created-barrier-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('agent-created-barrier-s'))).toBeUndefined() await ctx.fiber.dispose() }) @@ -717,13 +715,12 @@ describe('agent scope lifecycle', () => { let creating!: ReturnType ctx.on('agent/session-start', agent => void starts.push(agent.id)) ctx.on('agent/created', (agent) => { - if (agent.id === AgentId('listener-dispose')) void ownerCtx.fiber.dispose() + if (agent.id === SessionId('listener-dispose-s')) void ownerCtx.fiber.dispose() }) const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner creating = inner.agents.create({ - agentId: AgentId('listener-dispose'), sessionId: SessionId('listener-dispose-s'), agentOptions: { provider: 'mock', model: 'mock' }, }) @@ -732,7 +729,7 @@ describe('agent scope lifecycle', () => { await expect(creating).rejects.toThrow(/owner disposed during setup/) await owner.dispose() expect(starts).toEqual([]) - expect(ctx.agents.get(AgentId('listener-dispose'))).toBeUndefined() + expect(ctx.agents.get(SessionId('listener-dispose-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('listener-dispose-s'))).toBeUndefined() await ctx.fiber.dispose() }) @@ -741,20 +738,20 @@ describe('agent scope lifecycle', () => { const ctx = await harness() let ownerCtx!: Context let creating!: ReturnType - let announced!: ReactLoopAgent + let announced!: Agent const statuses: string[] = [] let scopeDisposed = false let observerSawLive = false ctx.on('agent/status', (agent, status) => { - if (agent.id === AgentId('session-start-dispose')) statuses.push(status) + if (agent.id === SessionId('session-start-dispose-s')) statuses.push(status) }) ctx.on('agent/session-start', (agent) => { - if (agent.id !== AgentId('session-start-dispose')) return - announced = agent as ReactLoopAgent + if (agent.id !== SessionId('session-start-dispose-s')) return + announced = agent disposeCurrentLifecycle(ownerCtx) }) ctx.on('agent/session-start', (agent) => { - if (agent.id !== AgentId('session-start-dispose')) return + if (agent.id !== SessionId('session-start-dispose-s')) return expect(ctx.agents.get(agent.id)).toBe(agent) expect(ctx.sessions.get(agent.session.id)).toBe(agent.session) agent.ctx.effect(() => () => { scopeDisposed = true }) @@ -764,7 +761,6 @@ describe('agent scope lifecycle', () => { const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner creating = inner.agents.create({ - agentId: AgentId('session-start-dispose'), sessionId: SessionId('session-start-dispose-s'), agentOptions: { provider: 'mock', model: 'mock' }, }) @@ -777,7 +773,7 @@ describe('agent scope lifecycle', () => { expect(observerSawLive).toBe(true) expect(scopeDisposed).toBe(true) expect(announced.session.events).toEqual([]) - expect(ctx.agents.get(AgentId('session-start-dispose'))).toBeUndefined() + expect(ctx.agents.get(SessionId('session-start-dispose-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('session-start-dispose-s'))).toBeUndefined() await ctx.fiber.dispose() }) @@ -789,7 +785,6 @@ describe('agent scope lifecycle', () => { ctx.on('agent/created', () => void published.push('agent/created')) ctx.on('agent/session-start', () => void published.push('agent/session-start')) await expect(ctx.agents.create({ - agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' }, setup: async () => { @@ -800,13 +795,13 @@ describe('agent scope lifecycle', () => { // Nothing leaked: no agent, no session, and the ids are reusable. expect(published).toEqual([]) - expect(ctx.agents.get(AgentId('bad'))).toBeUndefined() + expect(ctx.agents.get(SessionId('bad-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined() - const retry = await ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' } }) + const retry = await ctx.agents.create({ sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' } }) await retry.dispose() }) - it('rejects an exotic durable seed before publishing either identity', async () => { + it('rejects an exotic durable seed before publishing either object', async () => { const ctx = await harness() const published: string[] = [] ctx.on('session/created', () => { published.push('session') }) @@ -819,17 +814,15 @@ describe('agent scope lifecycle', () => { }] as unknown as SessionEvent[] await expect(ctx.agents.create({ - agentId: AgentId('exotic-seed'), sessionId: SessionId('exotic-seed-session'), agentOptions: { provider: 'mock', model: 'mock' }, seed, })).rejects.toThrow(/seed event at index 0 is not losslessly JSON-serializable/) expect(published).toEqual([]) - expect(ctx.agents.get(AgentId('exotic-seed'))).toBeUndefined() + expect(ctx.agents.get(SessionId('exotic-seed-session'))).toBeUndefined() expect(ctx.sessions.get(SessionId('exotic-seed-session'))).toBeUndefined() const retry = await ctx.agents.create({ - agentId: AgentId('exotic-seed'), sessionId: SessionId('exotic-seed-session'), agentOptions: { provider: 'mock', model: 'mock' }, }) @@ -845,13 +838,13 @@ describe('agent scope lifecycle', () => { if (boom) { boom = false; throw new Error('boom created') } }) await expect(ctx.agents.create({ - agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' }, + sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' }, })).rejects.toThrow('boom created') - expect(ctx.agents.get(AgentId('bad'))).toBeUndefined() + expect(ctx.agents.get(SessionId('bad-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined() expect(disposed).toEqual([]) // inserted but never announced: no impossible disposed edge // The rollback also disposed the scope fiber: re-creating works cleanly. - const retry = await ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' } }) + const retry = await ctx.agents.create({ sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' } }) expect(scopeOf(retry.agent.ctx)).toBe(retry.agent) await retry.dispose() }) @@ -868,18 +861,17 @@ describe('agent scope lifecycle', () => { ctx.on('agent/disposed', (agent) => { lifecycle.push(`agent-disposed:${agent.id}`) }) await expect(ctx.agents.create({ - agentId: AgentId('partial-agent'), sessionId: SessionId('partial-session'), agentOptions: { provider: 'mock', model: 'mock' }, })).rejects.toThrow('agent observer failed') expect(lifecycle).toEqual([ 'session-created:partial-session', - 'agent-created:partial-agent', - 'agent-disposed:partial-agent', + 'agent-created:partial-session', + 'agent-disposed:partial-session', 'session-disposed:partial-session', ]) - expect(ctx.agents.get(AgentId('partial-agent'))).toBeUndefined() + expect(ctx.agents.get(SessionId('partial-session'))).toBeUndefined() expect(ctx.sessions.get(SessionId('partial-session'))).toBeUndefined() }) @@ -894,23 +886,23 @@ describe('agent scope lifecycle', () => { } }) - expect(() => ctx.agentLoop.create(AgentId('config-bad'), { provider: 'mock', model: 'mock' })) + expect(() => ctx.agentLoop.create(SessionId('config-bad'), { provider: 'mock', model: 'mock' })) .toThrow('config publish failed') - expect(ctx.agents.get(AgentId('config-bad'))).toBeUndefined() + expect(ctx.agents.get(SessionId('config-bad'))).toBeUndefined() expect(ctx.sessions.list()).toHaveLength(sessionsBefore) }) it('registrations through a disposed agent ctx throw INACTIVE_EFFECT', async () => { const ctx = await harness() - const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { provider: 'mock', model: 'mock' } }) + const handle = await ctx.agents.create({ sessionId: SessionId('s1'), agentOptions: { provider: 'mock', model: 'mock' } }) await handle.dispose() expect(() => handle.agent.ctx.on('agent/status', () => {})).toThrow(/inactive context/) }) it('agentEvents fuses carrier and subject for custom drivers', async () => { const ctx = await harness() - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) - const other = ctx.agentLoop.create(AgentId('a2'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const other = ctx.agentLoop.create(SessionId('a2'), { provider: 'mock', model: 'mock' }) const heard: string[] = [] agent.ctx.on('agent/error', (subject: Agent, turn: number) => void heard.push(`${subject.id}:${turn}`)) @@ -923,7 +915,7 @@ describe('agent scope lifecycle', () => { const ctx = await harness() let handle!: Awaited> const owner = await ctx.plugin(Object.assign(async (inner: Context) => { - handle = await inner.agents.create({ agentId: AgentId('o1'), sessionId: SessionId('o1-s'), agentOptions: { provider: 'mock', model: 'mock' } }) + handle = await inner.agents.create({ sessionId: SessionId('o1-s'), agentOptions: { provider: 'mock', model: 'mock' } }) }, { inject: ['agents'] })) const { agent } = handle @@ -932,7 +924,7 @@ describe('agent scope lifecycle', () => { if (event.type === 'turn/end') order.push('turn-end') }) ctx.on('agent/disposed', () => { - order.push(`disposed(listed=${ctx.agents.get(AgentId('o1')) !== undefined})`) + order.push(`disposed(listed=${ctx.agents.get(SessionId('o1-s')) !== undefined})`) order.push(`session-still-stored=${ctx.sessions.get(SessionId('o1-s')) !== undefined}`) }) @@ -955,7 +947,7 @@ describe('agent scope lifecycle', () => { const ctx = await harness() let handle!: Awaited> const owner = await ctx.plugin(Object.assign(async (inner: Context) => { - handle = await inner.agents.create({ agentId: AgentId('h1'), sessionId: SessionId('h1-s'), agentOptions: { provider: 'mock', model: 'mock' } }) + handle = await inner.agents.create({ sessionId: SessionId('h1-s'), agentOptions: { provider: 'mock', model: 'mock' } }) }, { inject: ['agents'] })) const teardownDone: string[] = [] @@ -967,23 +959,22 @@ describe('agent scope lifecycle', () => { // actually finished (the raw wrapper returns undefined on a repeat call). await handle.dispose() expect(teardownDone).toContain('unregistered') - expect(ctx.agents.get(AgentId('h1'))).toBeUndefined() + expect(ctx.agents.get(SessionId('h1-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('h1-s'))).toBeUndefined() await unload }) it('successful handle disposal retires its caller ownership effect', async () => { const ctx = await harness() - const agentId = AgentId('retired-owner-effect') + const sessionId = SessionId('retired-owner-effect') const handle = await ctx.agents.create({ - agentId, - sessionId: SessionId('retired-owner-effect-s'), + sessionId, agentOptions: { provider: 'mock', model: 'mock' }, }) - expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain(`agentLoop.owner(${agentId})`) + expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain(`agentLoop.owner(${sessionId})`) await handle.dispose() - expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${agentId})`)).toEqual([]) + expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${sessionId})`)).toEqual([]) await ctx.fiber.dispose() }) @@ -994,7 +985,6 @@ describe('agent scope lifecycle', () => { let handle!: Awaited> const owner = await ctx.plugin(Object.assign(async (inner: Context) => { handle = await inner.agents.create({ - agentId: AgentId('manual-first'), sessionId: SessionId('manual-first-s'), agentOptions: { provider: 'mock', model: 'mock' }, setup(agentCtx) { @@ -1014,7 +1004,7 @@ describe('agent scope lifecycle', () => { expect(ownerSettled).toBe(false) gate.resolve(undefined) await Promise.all([disposing, unloading]) - expect(ctx.agents.get(AgentId('manual-first'))).toBeUndefined() + expect(ctx.agents.get(SessionId('manual-first-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('manual-first-s'))).toBeUndefined() await ctx.fiber.dispose() }) @@ -1024,13 +1014,11 @@ describe('agent scope lifecycle', () => { const gate = Promise.withResolvers() const cleanupStarted = Promise.withResolvers() const sessionDisposed = Promise.withResolvers() - const agentId = AgentId('quiescent-reuse') - const sessionId = SessionId('quiescent-reuse-s') + const sessionId = SessionId('quiescent-reuse') ctx.on('session/disposed', (session) => { if (session.id === sessionId) sessionDisposed.resolve(undefined) }) const first = await ctx.agents.create({ - agentId, sessionId, agentOptions: { provider: 'mock', model: 'mock' }, setup(agentCtx) { @@ -1043,10 +1031,10 @@ describe('agent scope lifecycle', () => { const disposing = first.dispose() await Promise.all([sessionDisposed.promise, cleanupStarted.promise]) - expect(ctx.agents.get(agentId)).toBeUndefined() + expect(ctx.agents.get(sessionId)).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() - const replacement = await ctx.agents.create({ agentId, sessionId, agentOptions: { provider: 'mock', model: 'mock' } }) - expect(ctx.agents.get(agentId)).toBe(replacement.agent) + const replacement = await ctx.agents.create({ sessionId, agentOptions: { provider: 'mock', model: 'mock' } }) + expect(ctx.agents.get(sessionId)).toBe(replacement.agent) expect(ctx.sessions.get(sessionId)).toBe(replacement.agent.session) gate.resolve(undefined) @@ -1058,7 +1046,6 @@ describe('agent scope lifecycle', () => { it('handle.dispose() awaits an idle-injection flush before unregistering or detaching', async () => { const ctx = await harness() const handle = await ctx.agents.create({ - agentId: AgentId('idle-flush'), sessionId: SessionId('idle-flush-s'), agentOptions: { provider: 'mock', model: 'mock' }, }) @@ -1077,12 +1064,12 @@ describe('agent scope lifecycle', () => { const disposal = handle.dispose().then(() => { disposed = true }) await new Promise(resolve => setTimeout(resolve, 0)) expect(disposed).toBe(false) - expect(ctx.agents.get(AgentId('idle-flush'))).toBe(handle.agent) + expect(ctx.agents.get(SessionId('idle-flush-s'))).toBe(handle.agent) expect(ctx.sessions.get(SessionId('idle-flush-s'))).toBe(handle.agent.session) gate.resolve(undefined) await disposal - expect(ctx.agents.get(AgentId('idle-flush'))).toBeUndefined() + expect(ctx.agents.get(SessionId('idle-flush-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('idle-flush-s'))).toBeUndefined() }) }) diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index 88ac16618b..c8794d49e9 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -6,13 +6,13 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { CallId, StreamChunk } from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionEvent } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import LlmService from '@deepseek-ai/dsh-llm' import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) { @@ -31,7 +31,7 @@ async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { dispose(); resolve() } @@ -39,7 +39,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } -function events(agent: ReactLoopAgent): SessionEvent[] { +function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } @@ -105,7 +105,7 @@ describe('tool-call scheduler: grouping and barriers', () => { const ctx = await harness(adapter) const gated = gatedParallelTool('p') ctx.tools.register(gated.tool) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 3) @@ -134,7 +134,7 @@ describe('tool-call scheduler: grouping and barriers', () => { name: 'w', description: 'write', parameters: { id: { type: 'string', required: true } }, async execute(args) { order.push(`w-${args.id}`); return [{ type: 'text', text: 'w' }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -169,7 +169,7 @@ describe('tool-call scheduler: grouping and barriers', () => { return [{ type: 'text', text: 'replaced' }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await until(() => replacement.started.length === 1) @@ -200,7 +200,7 @@ describe('tool-call scheduler: grouping and barriers', () => { disposeInitial() ctx.tools.register(replacement.tool) }) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await until(() => initial.started.length === 2) @@ -226,7 +226,7 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme const ctx = await harness(adapter) const gated = gatedParallelTool('p') ctx.tools.register(gated.tool) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 2) @@ -249,7 +249,7 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme const ctx = await harness(adapter) const gated = gatedParallelTool('p') ctx.tools.register(gated.tool) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 2) gated.release('2'); gated.release('1') @@ -294,7 +294,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => const ctx = await harness(adapter, 2) const gated = gatedParallelTool('p') ctx.tools.register(gated.tool) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 2) @@ -324,7 +324,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => const ctx = await harness(adapter, 1) const gated = gatedParallelTool('p') ctx.tools.register(gated.tool) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 1) await new Promise(r => setTimeout(r, 5)) @@ -351,7 +351,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => ctx.llm.registerAdapter(['mock'], adapter) const gated = gatedParallelTool('p') ctx.tools.register(gated.tool) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 1) await new Promise(r => setTimeout(r, 5)) @@ -377,7 +377,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () = const post: string[] = [] ctx.on('tools/pre-execute', async (exec, next): Promise => { pre.push(String(exec.callId)); return next() }) ctx.on('tools/post-execute', async (exec, _result, next): Promise => { post.push(String(exec.callId)); return next() }) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 3) @@ -398,7 +398,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () = ctx.tools.register(gated.tool) ctx.on('tools/post-execute', async (exec, _result): Promise => ({ kind: 'accept', additionalContexts: [{ content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' } }] })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 2) @@ -436,7 +436,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () = post.push(String(exec.callId)) return next() }) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 1) @@ -461,7 +461,7 @@ describe('tool-call scheduler: abort handling', () => { const ctx = await harness(adapter) const gated = gatedParallelTool('p') ctx.tools.register(gated.tool) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('session/event', (session, event) => { if (session === agent.session && event.type === 'assistant/message') { ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('already aborted') @@ -484,7 +484,7 @@ describe('tool-call scheduler: abort handling', () => { const ctx = await harness(adapter) const gated = gatedParallelTool('p') ctx.tools.register(gated.tool) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('tools/pre-execute', async (exec, next): Promise => { if (exec.callId === CallId('c1')) { ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('pre cancelled') @@ -517,7 +517,7 @@ describe('tool-call scheduler: abort handling', () => { ...await next(), additionalContexts: [{ content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' } }], })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 2) @@ -558,7 +558,7 @@ describe('tool-call scheduler: abort handling', () => { parameters: { id: { type: 'string', required: true } }, async execute(args) { exclusive.push(args.id); return [{ type: 'text', text: 'x' }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 2) diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index 3739428626..3346591811 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -9,13 +9,13 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { foldRequestHeader } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' import SystemPrompt, { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['toolOrder']) { @@ -31,7 +31,7 @@ async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['too return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -58,7 +58,7 @@ async function runTurn(registrationOrder: string[], toolOrder?: SystemPromptConf const adapter = new MockAdapter([textResponse('done')]) const ctx = await harness(adapter, toolOrder) for (const name of registrationOrder) registerNamed(ctx, name) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) return { ctx, agent, adapter } @@ -100,7 +100,7 @@ describe('loop-level canonical tool order', () => { registerNamed(ctx, 'alpha') const errors: Error[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) diff --git a/packages/core/agent-loop/tests/turn-stop.spec.ts b/packages/core/agent-loop/tests/turn-stop.spec.ts index 9fcf07ef9c..9920cb1c29 100644 --- a/packages/core/agent-loop/tests/turn-stop.spec.ts +++ b/packages/core/agent-loop/tests/turn-stop.spec.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { type TurnEndReason } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId, type ContinuationStop } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent, type ContinuationStop } from '@deepseek-ai/dsh-agent' import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -24,7 +24,7 @@ async function harness(adapter: MockAdapter): Promise { return ctx } -function send(agent: ReactLoopAgent, text = 'go'): Promise { +function send(agent: Agent, text = 'go'): Promise { agent.send([{ type: 'text', text }]) return agent.whenIdle() } @@ -47,7 +47,7 @@ describe('agent/turn-stop', () => { textResponse('must not be requested'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('terminal-steering'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('terminal-steering'), { provider: 'mock', model: 'mock' }) agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' })) let steered = false @@ -74,7 +74,7 @@ describe('agent/turn-stop', () => { textResponse('must not become a late-steering turn'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('terminal-flush-steering'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('terminal-flush-steering'), { provider: 'mock', model: 'mock' }) agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' })) let injected = false @@ -100,7 +100,7 @@ describe('agent/turn-stop', () => { textResponse('queued follow-up answer'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('terminal-flush-send'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('terminal-flush-send'), { provider: 'mock', model: 'mock' }) agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' })) let queued = false @@ -126,8 +126,8 @@ describe('agent/turn-stop', () => { ]) const ctx = await harness(adapter) registerEcho(ctx) - const stopped = ctx.agentLoop.create(AgentId('stopped'), { provider: 'mock', model: 'mock' }) - const ordinary = ctx.agentLoop.create(AgentId('ordinary'), { provider: 'mock', model: 'mock' }) + const stopped = ctx.agentLoop.create(SessionId('stopped'), { provider: 'mock', model: 'mock' }) + const ordinary = ctx.agentLoop.create(SessionId('ordinary'), { provider: 'mock', model: 'mock' }) stopped.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' })) await send(stopped) @@ -147,7 +147,7 @@ describe('agent/turn-stop', () => { ]) const ctx = await harness(adapter) registerEcho(ctx) - const agent = ctx.agentLoop.create(AgentId('owned-listener'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('owned-listener'), { provider: 'mock', model: 'mock' }) const disposeStop = agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' })) await send(agent, 'first turn') @@ -164,7 +164,7 @@ describe('agent/turn-stop', () => { textResponse('healthy later turn'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('bad-policy'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('bad-policy'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] const errors: string[] = [] ctx.on('session/event', (session, event) => { diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 1255731484..122e58f39d 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -8,28 +8,30 @@ Tracks live agents so UI, hook, and orchestrator plugins can find them without i ### Public API -`Agent.ctx` owns registrations visible only to that agent. `agentEvents()` couples event subjects to their scope carrier, and `assembleContextFor()` couples the agent and prompt scope. Creation and resume may compose this context through `setup`; the agent remains unpublished and must not be driven until creation resolves. +The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves. - `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber. -- Advanced factory lifecycle: `enter(agent)` publishes without announcing and returns an entry-bound detach; `announce(agent)` emits creation once. Detach during creation dispatch is deferred. Ordinary plugins use `register()`. -- `ctx.agents.get(id: AgentId): Agent | undefined` +- Advanced ordered lifecycle: `enter(agent, owner): () => void` enforces `agent.id === agent.session.id`, performs the authoritative ID collision check, and inserts without announcing; `owner` explicitly records the live creator-agent relation (or `undefined` for a root), independently of durable session lineage. `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`. +- `ctx.agents.get(id: SessionId): Agent | undefined` +- `ctx.agents.isOwnedBy(id: SessionId, owner: Agent): boolean` — whether the exact live entry was created through that parent agent's scoped context; runtime ownership is independent of durable session lineage. - `ctx.agents.list(): Agent[]` +- `ctx.agents.roots(): Agent[]` — live agents created without an owning agent context; a resumed lineage-bearing session can still be a runtime root. #### Factory seam (creation) -The loop plugin registers `AgentFactory`, keeping consumers independent of its concrete package. Each call is traced through the caller's context so the caller owns the resulting transaction and handle. +Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package. The registry canonicalizes an already traced Service to its concrete target and re-traces each call through the caller's context; this avoids nested Cordis shadows while passing an explicit caller-bound `ownerCtx` to plain factories. - `ctx.agents.setFactory(factory: AgentFactory): () => void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose. -- `ctx.agents.create(options)` creates and composes an unpublished session and agent, then atomically enters the registries and starts the loop. A creation-only signal cancels before publication; same-ID contenders arbitrate at entry and losers roll back. -- `ctx.agents.resume(options)` loads a persisted session and follows the same composition and publication boundary. It requires [session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md). +- `ctx.agents.create(options: CreateAgentOptions): Promise` — create a session and agent, await optional setup while unpublished, then publish through final `SessionStore.enter()` and `AgentRegistry.enter()` checks. Concurrent same-ID creation is unsupported: more than one operation may prepare, but only one can enter; every loser rolls its private scope/session/driver back. An optional creation-only `signal` cancels unpublished setup and is detached before the handle is returned; later cancellation uses `handle.dispose()` or `agent.cancel()`. Publication is rollback-covered and every delivered creation edge is paired during rollback. Rejects if no factory is registered. +- `ctx.agents.resume(options: ResumeAgentOptions): Promise` — load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh unpublished agent scope, await optional setup, and use the same final-entry publication sequence. Its optional `signal` is likewise creation-only. Rejects if no factory is registered or session persistence is unconfigured. -`AgentHandle = { agent, dispose }` is the consumer teardown capability; registry observers receive only the bare agent. Disposal stops and drains the loop and idle-injection flushes before unregistering the agent, detaching its session, and unwinding its scope. Caller and factory unload share that memoized boundary. +`AgentHandle = { agent: Agent; dispose(): Promise }`. The disposer is a **consumer capability** — no observer holding the bare registry entry can tear the agent down. The caller fiber and the registered factory provider are structural co-owners: caller unload enforces structured ownership, while factory unload must stop old instances because their scoped dependency surface belongs to that provider. `dispose()` from any owner reaches one memoized quiescence boundary: it stops the loop, `await`s its exit plus every outstanding idle-injection flush (not just the `disposed` status flip), unregisters the agent, removes its session from the store, and finally unwinds its scoped world. This order captures every agent-started `session/flush` before the session is detached and keeps scoped listeners alive through those checkpoints. `ctx.agents.get(id)` still returns a bare `Agent`; the ACP bridge and in-process subagent backends hold consumer handles, while config-created agents are already owned by the loop fiber. ### Live events `dsh-agent` declares the live `agent/*` coordination vocabulary so plugins do not depend on the concrete loop. Exact signatures, dispatch modes, scope-filtering rules, and payload contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the [architecture turn flow](../../../docs/architecture.md#turn-flow) shows their order relative to durable session events. -`agent/created` runs after setup and both registry entries; the following `agent/session-start` is the first supported startup injection point. `agent/disposed` means the exact entry left the registry. The loop quiesces its driver first; directly registered custom agents own any stronger ordering. +The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves. Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` is a serial surface-mutation checkpoint, while `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale is in the [agent-scope runtime-design RFC](../../../docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way). @@ -75,3 +77,4 @@ The handle every plugin programs against: - **No public step-only abort** — `cancel()` clears ALL pending work (queued + steering + in-flight); an abort that preserves queued prompts returns only with a named consumer ([stop-surface RFC](../../../docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md)). - **`HookContext` carries exactly one `MessageSource`** — contributions from several plugins merged onto one tool call collapse under one source; mixed provenance is unrepresentable. - **`SessionStartSource` reserves `'clear'`/`'compact'` with no emitter yet** — only `'startup'`/`'resume'` occur until the driving subsystems land (`TODO(compaction)`). +- **`agent/pre-step`'s `fullSystemPrompt`/`sessionPrefix` parameters are a flagged smell** — compaction is their only consumer; a lazy prompt provider or a compaction-specific pressure seam is the marked revisit. diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index fc5ad371b4..8a9ed68ab6 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -9,7 +9,7 @@ import { Context, getTraceable, Service, symbols } from 'cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' -import type { Agent, AgentId, AgentOptions } from './types.ts' +import type { Agent, AgentOptions } from './types.ts' export * from './types.ts' export { agentEvents, assembleContextFor } from './dispatch.ts' @@ -31,23 +31,57 @@ declare module 'cordis' { } } -/** Options for creating an agent and its caller-named session. */ +/** + * Options for programmatically creating an agent through the registry factory + * ({@link AgentRegistry.create}). The caller supplies the single live + * `sessionId` shared by the agent registry and session log (e.g. an + * ACP-generated id), plus optional session metadata (the validated `cwd`, fork + * lineage); the factory creates the session and agent under that identity. + */ export interface CreateAgentOptions { - /** The agent's id (the registry handle). */ - readonly agentId: AgentId - /** The live session's id (NOT derived from agentId). */ + /** The live agent/session identity. */ readonly sessionId: SessionId - /** Durable session metadata, validated and detached before setup. */ + /** + * Session creation metadata: validated absolute `cwd`, `parentSession` + * fork lineage, and the `seedLength` seed boundary. Mirrors the + * `cwd`/`parentSession`/`seedLength` fields of + * {@link CreateSessionOptions.meta} in dsh-session (the internal-only + * `createdAt`, used when reconstructing a persisted session, is deliberately + * excluded — a factory caller never sets it). This is durable session data, + * so the session boundary validates and snapshots it before asynchronous + * setup begins. + */ readonly meta?: { readonly cwd?: string; readonly parentSession?: SessionId; readonly seedLength?: number } - /** Balanced contiguous event prefix for a forked session. */ + /** + * Seed events to reconstruct the child session's log from (the fork lineage + * primitive). When present, the factory creates the session with this event + * prefix so `deriveMessages()`/`lastTurnNumber` continue from it — used by the + * in-process FORK subagent backend to seed a child with a balanced + * completed-turn prefix of the parent's log. The prefix MUST be contiguous + * from seq 0, carry only lossless-JSON data, and be balanced (no open + * turn/step, no dangling tool-call), or the session constructor (and the + * dev-mode invariants replay) reject it. The factory passes the raw seed to + * the session's durable validator/snapshot boundary. Absent for a fresh + * (spawn) child. + */ readonly seed?: readonly SessionEvent[] /** Per-agent options (model, …). */ readonly agentOptions?: AgentOptions /** Optional creation-only cancellation signal; detached before the returned handle becomes visible. */ readonly signal?: AbortSignal /** - * Compose the unpublished scoped context before lifecycle announcements. - * Failure rolls back without publishing either id; setup must not drive the agent. + * Creation-time composition of the agent's scoped world. The factory awaits + * setup after minting `agentCtx` but BEFORE inserting or announcing either + * the session or agent, so observers can never see a partially configured + * world. Everything registered through `agentCtx` (scoped tools, prompt + * sections/variables, `restrict()`, listeners, awaited child plugins) exists + * before `session/created`, `agent/created`, `agent/session-start`, and the + * first prompt assembly. A throw/rejection or owner disposal rolls the scope + * back without publishing either id. + * + * **Setup composes, it never drives**: the callback is trusted same-process + * code and receives the full scoped context, so this is a contract rather + * than a runtime restriction. Drive the agent only after creation resolves. */ readonly setup?: (agentCtx: Context) => Promise | void } @@ -57,23 +91,41 @@ export interface CreateAgentOptions { * ({@link AgentRegistry.resume}). */ export interface ResumeAgentOptions { - /** The agent's id (the registry handle). */ - readonly agentId: AgentId - /** The persisted session id to load and resume on. */ + /** The persisted session id to load and use as the live agent/session identity. */ readonly resumeSessionId: SessionId /** Per-agent options (model, …). */ readonly agentOptions?: AgentOptions /** Optional creation-only cancellation signal for persistence load/setup; detached before return. */ readonly signal?: AbortSignal - /** Compose after persistence load under the same unpublished rollback contract as create. */ + /** + * Resume-time composition of the agent's fresh scoped world. Persistence is + * loaded first; the factory then mints `agentCtx` and awaits setup while the + * reconstructed session and agent remain unpublished. The callback has the + * same trusted composition-only contract as + * {@link CreateAgentOptions.setup}: all registrations exist before either + * creation announcement, and rejection or owner disposal rolls the + * transaction back without publishing either id. + */ readonly setup?: (agentCtx: Context) => Promise | void } /** - * Holder-owned agent capability. Disposal stops and drains the loop and idle - * flushes before unregistering the agent, detaching its session, and unwinding - * its scoped context. Provider unload reaches the same quiescence boundary; - * registry observers receive only the bare {@link Agent}. + * An owned agent plus its disposer, returned by {@link AgentRegistry.create} / + * {@link AgentRegistry.resume}. The disposer is a CAPABILITY: among consumers, + * only the holder can tear this agent down. The registered factory provider is + * also a structural owner because the scoped agent depends on that provider's + * service surface; provider unload stops and drains every live handle it made. + * `dispose()` stops the loop, awaits its exit and every outstanding + * idle-injection flush (quiescence — NOT just the `disposed` + * status flip), unregisters the agent, removes its session from the store, and + * finally unwinds its scoped world. This order captures every agent-started + * `session/flush` before the session is detached and keeps scoped listeners + * alive through those checkpoints. + * + * `ctx.agents.get(id)` still returns a bare {@link Agent} — the handle is + * exposed only to the consumer owner that created it; the structural provider + * reaches the same teardown internally. Config-created agents (the loop's own + * startup) are owned by the loop fiber and never need a handle. */ export interface AgentHandle { agent: Agent @@ -88,16 +140,30 @@ export interface AgentHandle { */ export interface AgentFactory { /** - * Create and compose under caller ownership, publish and announce session then - * agent, emit session-start, and start the driver. Rollback pairs any creation - * announcement that began. + * Create a new agent on a caller-supplied session id. Async because creation + * awaits unpublished setup, inserts both session and agent, emits their + * creation notifications in order, emits `agent/session-start`, and only + * then starts the loop. The sequence is + * rollback-covered, but notifications delivered before a later listener + * failure remain observable; every agent or session creation announcement + * that began is paired by `agent/disposed` or `session/disposed` during + * rollback. The owner disposes the resolved handle to stop/drain, + * unregister, remove the session, and unwind the scope. + * The registry passes a context carrying the `create()` caller's fiber and + * scope as `ownerCtx`. The implementation attaches the unpublished + * transaction and resulting lifecycle to that owner; it must not infer + * ownership from the factory object's registration context. * @param ownerCtx - caller-bound context that owns the transaction and live handle. * @param options - agent/session identity, configuration, and optional setup. * @returns the owned handle after setup, both announcements, and loop start complete. */ createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise /** - * Load, compose, publish, announce, and resume an agent under caller ownership. + * Load a persisted session and resume an agent on it. Async because it awaits + * both `ctx.sessionPersistence.load` and the optional unpublished setup + * transaction; must be called after that service exists (consumers inject + * `sessionPersistence`). Publication follows the same ordered boundary as + * {@link createAgent}. * @param ownerCtx - caller-bound context that owns load, setup, and the live handle. * @param options - persisted identity, configuration, and optional setup. * @returns the owned handle after setup, both announcements, and loop start complete. @@ -110,8 +176,10 @@ const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plug /** All mutable lifecycle state for one exact registry entry. */ interface AgentEntry { - readonly id: AgentId + readonly id: SessionId readonly agent: Agent + /** Runtime creator-agent ownership; independent of durable session lineage. */ + readonly owner: Agent | undefined readonly carrier: Scoped announced: boolean announcing: boolean @@ -131,33 +199,46 @@ interface FactorySlot { * {@link setFactory}. */ export class AgentRegistry extends Service { - private store = new Map() - // TODO(agent-entry-mirror): derive exact-object checks from store.get(agent.id) - // plus entry.agent identity; this WeakMap mirrors the authoritative id map. - private entries = new WeakMap() + private store = new Map() private factory: FactorySlot | undefined constructor(ctx: Context) { super(ctx, 'agents') - // Agent contexts shadow this plain-context default with an own property. + // The `ctx.agent` DX accessor: default `undefined` on every context, so a + // plain plugin context reads cleanly instead of hitting the Cordis + // unknown-property throw. Each Agent.ctx shadows it with an own property + // (own properties resolve before the context proxy is consulted), so the + // accessor body never needs to resolve a scope itself. Effect-scoped: + // unwinds with this service's fiber. ctx.accessor('agent', { get: () => undefined }) } /** - * Register the effect-scoped creation factory, rejecting a duplicate. Service - * factories are retraced through each create/resume caller for ownership. + * Register the agent-creation factory (the loop calls this on construction, + * effect-scoped). A traced Cordis service is canonicalized to its concrete + * target; each create/resume call is then traced through that caller's + * context so ownership follows the caller without stacking proxy layers. + * Throws if a factory is already registered. Returns the disposer; on + * dispose the factory slot is cleared. * @param factory - the loop-owned factory {@link create}/{@link resume} delegate to. - * @returns the exact Cordis effect disposer. + * @returns the disposer that clears the factory slot. The exact + * Cordis effect disposer (single-shot): composite (generator) effects may + * yield it directly — exact identity nests the teardown in order. */ setFactory(factory: AgentFactory): () => void { const dispose = this.ctx.effect(() => { if (this.factory !== undefined) throw new Error('an agent factory is already registered') - // Store the concrete service; calls are retraced through their owner. + // Avoid stacking two Cordis shadow layers when a caller passes a Service + // already read through a context. Calls are re-traced through their + // actual owner context below. const target = (factory as AgentFactory & { [symbols.original]?: AgentFactory })[symbols.original] ?? factory this.factory = { target } return () => { this.factory = undefined } }, 'agents.setFactory()') - // Return the exact disposer so composite effects preserve teardown order. + // The exact cordis effect disposer (the agents.register() convention): a + // caller's composite effect can yield it for in-order teardown; the + // loop's constructor effect returns it directly, identity-nesting the + // registration under that effect. // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity return dispose } @@ -169,14 +250,20 @@ export class AgentRegistry extends Service { } /** - * Create and publish an owned agent and session through the active factory. - * Rejects if no factory is registered or creation, setup, or publication fails. - * @param options - agent id, session id/seed/metadata, and agent options. + * Create and publish a new agent through the registered factory. + * Distinct from {@link register} (which records an already-constructed + * agent): this constructs the agent and its session. Rejects if no factory is + * registered or creation/setup fails. The resolved {@link AgentHandle} lets + * the owner tear down exactly this agent. + * @param options - shared identity, session seed/metadata, and agent options. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ async create(options: CreateAgentOptions): Promise { const ownerCtx = this.ctx - // Bind service effects to this caller while preserving factory dependencies. + // Re-trace a Service-backed factory through the accessing context + // explicitly. This preserves AgentLoop's dependency origin while binding + // its effects to ownerCtx; plain factories receive ownerCtx as an explicit + // capability and need no Cordis tracker magic. const { target } = this.requireFactory() const receiver = getTraceable(ownerCtx, target) // eslint-disable-next-line @typescript-eslint/unbound-method -- Reflect.apply intentionally supplies the caller-traced receiver @@ -199,14 +286,26 @@ export class AgentRegistry extends Service { } /** - * Register a live agent in the calling effect scope, with scope-filtered - * creation and disposal events. Duplicate ids throw. + * Register a live agent. Throws if an agent with the same id is already + * registered. Emits `agent/created` on registration and `agent/disposed` + * when the calling fiber is disposed — both with the agent's scope carrier + * (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the + * emits are scope-filtered regardless of which context invoked `register` + * (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always + * requires passing the carrier). Returns the disposer. * @param agent - the already-constructed agent to record in the store. - * @returns the exact Cordis effect disposer for nested teardown ordering. + * @returns the EXACT Cordis effect disposer (single-shot; a repeat call + * returns undefined without awaiting an in-flight teardown). Exact + * identity is load-bearing: a composite (generator) effect that owns a + * teardown ORDER — the agent factory's lifecycle chain — must yield THIS + * function so Cordis nests the unregistration at that yield position; + * yielding a wrapper would leave it disposing as a concurrent sibling on + * owner unload, unregistering the agent (and emitting `agent/disposed`) + * while its final turn is still draining. */ register(agent: Agent): () => void { const dispose = this.ctx.effect(function* (this: AgentRegistry) { - yield this.enter(agent) + yield this.enter(agent, this.ctx.agent) this.announce(agent) }.bind(this), 'agents.register()') // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity @@ -214,31 +313,48 @@ export class AgentRegistry extends Service { } /** - * Insert an unpublished agent for an ordered factory transaction. + * Insert an already-constructed agent without announcing it. This is the + * advanced ordered-lifecycle primitive used by the async agent factory: it + * first completes setup while the agent is unpublished, then assigns the + * returned detach closure into its pre-installed composite teardown before + * calling {@link announce}. Ordinary callers use {@link register}. * @param agent - the prepared, unpublished agent. - * @returns an idempotent closure that removes this exact entry and emits the - * paired disposal edge; detachment during creation dispatch is deferred. + * @param owner - live agent whose scoped context created this agent, or + * undefined for a top-level runtime root. This is runtime ownership, not + * the resumed session's durable parent lineage. + * @returns an idempotent closure that removes this exact entry and emits + * `agent/disposed` with listener failures contained. When called from a + * synchronous `agent/created` listener, removal and disposal wait until + * that creation dispatch unwinds. */ - enter(agent: Agent): () => void { + enter(agent: Agent, owner: Agent | undefined): () => void { const id = agent.id + if (id !== agent.session.id) { + throw new Error(`agent id "${id}" does not match session id "${agent.session.id}"`) + } const carrier = scopeTarget(agent, agent) - // Prepared transactions arbitrate identity at this publication boundary. - if (this.entries.has(agent) || this.store.has(id)) throw new Error(`agent "${id}" is already registered`) + // This is the authoritative collision boundary. Concurrent create/resume + // operations may both prepare, but only one exact entry can publish. + if (this.store.has(id)) throw new Error(`agent "${id}" is already registered`) const entry: AgentEntry = { id, agent, + owner, carrier, announced: false, announcing: false, detachRequested: false, } this.store.set(id, entry) - this.entries.set(agent, entry) let entered = true const detach = (): void => { if (!entered) return entered = false - // Creation listeners observe one stable entry before paired disposal. + // Every callback reached by this creation dispatch must observe the same + // live entry, and disposal must follow creation. A listener may own + // the advanced detach capability, so make that ordering structural: + // visibility and the paired disposal are deferred until announce()'s + // synchronous dispatch has unwound. if (entry.announcing) { entry.detachRequested = true return @@ -256,7 +372,6 @@ export class AgentRegistry extends Service { /* v8 ignore next -- enter() rejects replacement while this single-shot detach capability is live. */ if (this.store.get(entry.id) !== entry) return this.store.delete(entry.id) - this.entries.delete(entry.agent) // An insertion rolled back before announce was never externally created, // so emitting disposed would invent an impossible lifecycle edge. Marking // happens before the created emit: if a later created listener throws, @@ -288,8 +403,8 @@ export class AgentRegistry extends Service { * creation listener). */ announce(agent: Agent): void { - const entry = this.entries.get(agent) - if (entry === undefined || this.store.get(entry.id) !== entry) { + const entry = this.store.get(agent.id) + if (entry === undefined || entry.agent !== agent) { throw new Error(`agent "${agent.id}" is not live in this registry`) } if (entry.announced || entry.announcing) { @@ -318,13 +433,25 @@ export class AgentRegistry extends Service { /** * Look up a live agent. - * @param id - the agent id to look up. + * @param id - the shared agent/session id to look up. * @returns the agent, or undefined when no live agent has that id. */ - get(id: AgentId): Agent | undefined { + get(id: SessionId): Agent | undefined { return this.store.get(id)?.agent } + /** + * Test whether a live agent was created through one exact parent agent's + * scoped context. Runtime ownership is independent of durable session + * lineage and remains unambiguous when unrelated providers reuse an id. + * @param id - the candidate child agent's shared agent/session id. + * @param owner - the expected runtime creator agent. + * @returns true only while the exact child entry is live under that owner. + */ + isOwnedBy(id: SessionId, owner: Agent): boolean { + return this.store.get(id)?.owner === owner + } + /** * All live agents, in registration order. * @returns a fresh array; mutating it does not affect the registry. @@ -332,6 +459,18 @@ export class AgentRegistry extends Service { list(): Agent[] { return [...this.store.values()].map(entry => entry.agent) } + + /** + * All live top-level agents in registration order. A top-level agent was + * created without an owning agent context; durable session lineage does not + * affect this runtime relation, so a resumed fork may still be a root. + * @returns a fresh array; mutating it does not affect the registry. + */ + roots(): Agent[] { + return [...this.store.values()] + .filter(entry => entry.owner === undefined) + .map(entry => entry.agent) + } } export default AgentRegistry diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 5c709c828b..e2f0b77604 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -5,25 +5,11 @@ * @module @deepseek-ai/dsh-agent/types */ -import type { Branded } from '@deepseek-ai/dsh-brand' import type { Context } from 'cordis' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { ContentBlock, LlmCallConfig, Message, MessageSource } from '@deepseek-ai/dsh-llm' +import type { ContextEnvelope, JsonValue, Session, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' - -/** Identifies one live agent in the registry. */ -export type AgentId = Branded<'AgentId'> - -/** - * Brand a string as an {@link AgentId}. - * @param id - the raw agent id string. - * @returns the same string, branded (a compile-time cast — no runtime cost). - */ -export function AgentId(id: string): AgentId { - return id as AgentId -} -import type { ContextEnvelope, JsonValue, Session } from '@deepseek-ai/dsh-session' - declare module '@deepseek-ai/dsh-system-prompt' { interface AssembleContext { /** Agent for this assembly; absent on diagnostics. When present, `scope` must identify the same agent. */ @@ -94,9 +80,10 @@ export type ContinuationStop = Extract /** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */ export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' -/** Public agent handle; the concrete driver belongs to `@deepseek-ai/dsh-agent-loop`. */ +/** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop`. */ export interface Agent { - readonly id: AgentId + /** The single identity shared with {@link session}. */ + readonly id: SessionId readonly options: AgentOptions readonly session: Session readonly status: AgentStatus diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 5d541d56a8..5d79d1a91f 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -2,15 +2,16 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context, Service, symbols } from 'cordis' import type { Events } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { AgentId, agentEvents } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' + import type { Agent, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent' function stubAgent(rawId: string): Agent { - const id = AgentId(rawId) + const id = SessionId(rawId) return { id, options: {}, - session: new Session(SessionId(`${id}-session`)), + session: new Session(id), status: 'idle', ctx: new Context(), send() {}, @@ -41,6 +42,7 @@ describe('AgentRegistry', () => { const dispose = ctx.agents.register(agent) expect(ctx.agents.get(agent.id)).toBe(agent) expect(ctx.agents.list()).toEqual([agent]) + expect(ctx.agents.roots()).toEqual([agent]) expect(() => ctx.agents.register(stubAgent('a1'))).toThrow(/already registered/) dispose() @@ -48,6 +50,37 @@ describe('AgentRegistry', () => { expect(lifecycle).toEqual(['created:a1', 'disposed:a1']) }) + it('rejects an agent whose registry and session identities differ', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const agent = { ...stubAgent('agent-id'), session: new Session(SessionId('session-id')) } + + expect(() => ctx.agents.enter(agent, undefined)) + .toThrow('agent id "agent-id" does not match session id "session-id"') + expect(ctx.agents.list()).toEqual([]) + }) + + it('tracks runtime creator ownership separately from registry order', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const root = stubAgent('root') + const child = stubAgent('child') + const detachRoot = ctx.agents.enter(root, undefined) + ctx.agents.announce(root) + const detachChild = ctx.agents.enter(child, root) + ctx.agents.announce(child) + + expect(ctx.agents.list()).toEqual([root, child]) + expect(ctx.agents.roots()).toEqual([root]) + expect(ctx.agents.isOwnedBy(child.id, root)).toBe(true) + expect(ctx.agents.isOwnedBy(root.id, root)).toBe(false) + expect(ctx.agents.isOwnedBy(SessionId('missing'), root)).toBe(false) + + detachChild() + expect(ctx.agents.isOwnedBy(child.id, root)).toBe(false) + detachRoot() + }) + it('rolls an entry back and pairs a partially delivered creation when a listener throws', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) @@ -57,7 +90,7 @@ describe('AgentRegistry', () => { ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`)) expect(() => ctx.agents.register(stubAgent('vetoed'))).toThrow('creation veto') - expect(ctx.agents.get(AgentId('vetoed'))).toBeUndefined() + expect(ctx.agents.get(SessionId('vetoed'))).toBeUndefined() expect(lifecycle).toEqual(['created:vetoed', 'disposed:vetoed']) }) @@ -93,7 +126,7 @@ describe('AgentRegistry', () => { ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`)) const first = stubAgent('split') - const detachFirst = ctx.agents.enter(first) + const detachFirst = ctx.agents.enter(first, undefined) expect(lifecycle).toEqual([]) ctx.agents.announce(first) expect(() => { ctx.agents.announce(first) }).toThrow(/already announced/) @@ -101,7 +134,7 @@ describe('AgentRegistry', () => { detachFirst() const replacement = stubAgent('split') - const detachReplacement = ctx.agents.enter(replacement) + const detachReplacement = ctx.agents.enter(replacement, undefined) detachFirst() expect(ctx.agents.get(replacement.id)).toBe(replacement) expect(() => { ctx.agents.announce(first) }).toThrow(/not live/) @@ -121,7 +154,7 @@ describe('AgentRegistry', () => { }) ctx.on('agent/created', () => void order.push(`second:${ctx.agents.get(agent.id) === agent}`)) ctx.on('agent/disposed', () => void order.push('disposed')) - const detach = ctx.agents.enter(agent) + const detach = ctx.agents.enter(agent, undefined) ctx.agents.announce(agent) expect(order).toEqual(['first:true', 'after-detach:true', 'second:true', 'disposed']) expect(ctx.agents.get(agent.id)).toBeUndefined() @@ -158,11 +191,11 @@ describe('AgentRegistry factory seam', () => { const factory: AgentFactory = { async createAgent(ownerCtx, options) { calls.create.push({ ownerCtx, options }) - return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() } + return { agent: stubAgent(options.sessionId), dispose: () => Promise.resolve() } }, async resume(ownerCtx, options) { calls.resume.push({ ownerCtx, options }) - return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() } + return { agent: stubAgent(options.resumeSessionId), dispose: () => Promise.resolve() } }, } return { factory, calls } @@ -171,15 +204,15 @@ describe('AgentRegistry factory seam', () => { it('requires a factory and delegates through the calling context', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) - await expect(ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).rejects.toThrow(/no agent factory/) + await expect(ctx.agents.create({ sessionId: SessionId('s') })).rejects.toThrow(/no agent factory/) const { factory, calls } = stubFactory() ctx.agents.setFactory(factory) let callerFiber: Context['fiber'] | undefined await ctx.plugin(Object.assign(async (inner: Context) => { callerFiber = inner.fiber - await inner.agents.create({ agentId: AgentId('create'), sessionId: SessionId('create-s') }) - await inner.agents.resume({ agentId: AgentId('resume'), resumeSessionId: SessionId('resume-s') }) + await inner.agents.create({ sessionId: SessionId('create-s') }) + await inner.agents.resume({ resumeSessionId: SessionId('resume-s') }) }, { inject: ['agents'] })) expect(calls.create[0]?.ownerCtx.fiber).toBe(callerFiber) expect(calls.resume[0]?.ownerCtx.fiber).toBe(callerFiber) @@ -192,9 +225,9 @@ describe('AgentRegistry factory seam', () => { inner.agents.setFactory(stubFactory().factory) expect(() => inner.agents.setFactory(stubFactory().factory)).toThrow(/already registered/) }, { inject: ['agents'] })) - await expect(ctx.agents.create({ agentId: AgentId('before'), sessionId: SessionId('before-s') })).resolves.toBeDefined() + await expect(ctx.agents.create({ sessionId: SessionId('before-s') })).resolves.toBeDefined() await owner.dispose() - await expect(ctx.agents.create({ agentId: AgentId('after'), sessionId: SessionId('after-s') })).rejects.toThrow(/no agent factory/) + await expect(ctx.agents.create({ sessionId: SessionId('after-s') })).rejects.toThrow(/no agent factory/) }) it('canonicalizes an already traced Service before tracing it for the caller', async () => { @@ -214,18 +247,18 @@ describe('AgentRegistry factory seam', () => { } async createAgent(_ownerCtx: Context, options: CreateAgentOptions) { this.calls().push('create') - return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() } + return { agent: stubAgent(options.sessionId), dispose: () => Promise.resolve() } } async resume(_ownerCtx: Context, options: ResumeAgentOptions) { this.calls().push('resume') - return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() } + return { agent: stubAgent(options.resumeSessionId), dispose: () => Promise.resolve() } } } await ctx.plugin(TracedFactory) const traced = (ctx as Context & { tracedFactory: TracedFactory }).tracedFactory ctx.agents.setFactory(traced) - await ctx.agents.create({ agentId: AgentId('create'), sessionId: SessionId('create-s') }) - await ctx.agents.resume({ agentId: AgentId('resume'), resumeSessionId: SessionId('resume-s') }) + await ctx.agents.create({ sessionId: SessionId('create-s') }) + await ctx.agents.resume({ resumeSessionId: SessionId('resume-s') }) const raw = (traced as unknown as { [symbols.original]?: TracedFactory })[symbols.original] expect(states.get(raw!)).toEqual(['create', 'resume']) }) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 985d475101..ef84a6bfbc 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -279,7 +279,12 @@ export class Session { */ readonly header: SessionHeader - constructor(public readonly id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) { + /** The session identity, derived from its durable header's single copy. */ + get id(): SessionId { + return this.header.id + } + + constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) { if (seed) { // Validate the seed to the SAME invariants `append` enforces, so a // replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index cfe2fdf2fa..bfd75fdcb6 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -154,7 +154,7 @@ The available tools: - **Concurrency policy is not an event seam** — `executionMode()` reads the resolved tool definition directly; plugins can only declare a classifier on definitions they own. - **`tools/pre-execute` deliberately cannot rewrite `exec.arguments`** — logged and rendered args would desync from what ran; the rewrite design is [a proposed RFC](../../../docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md). -- **`defineTool`'s schema DSL is a deliberate subset** — string/number/boolean/object/array with string-only `enum`; `validateArgs` tolerates extra keys and never applies `default` (`XXX(unused-default)` flags removing that field); raw-registered JSON-Schema tools validate their own input. +- **`defineTool`'s schema DSL is a deliberate subset** — string/number/boolean/object/array with string-only `enum`; `validateArgs` tolerates extra keys and preserves `default` as a model-visible JSON Schema annotation without applying it during validation; dynamic Cordis mounts may supply defaults even though first-party definitions do not, while raw-registered JSON-Schema tools validate their own input. - **`timeoutMs` on a definition is declarative only** — the registry never enforces deadlines; enforcement requires the `@deepseek-ai/dsh-timeout-policy` wrapper. - **Code Mode is TypeScript-only and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language === 'typescript'`; scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only. - **Code Mode bindings return text only** — non-text content blocks in a sub-call result collapse to `[ content]` placeholders. diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts index c61c819618..f2b62669b1 100644 --- a/packages/core/tools/src/schema.ts +++ b/packages/core/tools/src/schema.ts @@ -21,12 +21,8 @@ export interface SchemaProp { /** Enum of allowed values (strings only). */ enum?: string[] /** - * Default value, emitted into the JSON Schema only (validation never applies - * it — see the validator note below). - * - * XXX(unused-default): no tool definition in the repo sets `default`; it rides - * into the wire schema for a model that no tool surfaces it to. Drop the field - * and its converter line unless a real tool needs a model-visible default. + * Model-visible JSON Schema default annotation. Validation does not apply it; + * dynamic tool mounts may supply it even though first-party definitions do not. */ default?: unknown /** Nested properties for type: 'object'. */ diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index d4660f7ed7..bbc19d5d75 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -8,7 +8,6 @@ import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools' import type { Config, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools' -import { AgentId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEventMap } from '@deepseek-ai/dsh-session' @@ -59,7 +58,7 @@ async function setup(options: SetupOptions = {}) { /** Mint one production-shaped agent scope that can register scoped tool policy. */ async function mintAgentScope(ctx: Context, name = 'scoped'): Promise<{ scope: Scope; agent: Agent }> { - const agent = { id: AgentId(name) } as Agent + const agent = { id: SessionId(name) } as Agent let scope!: Scope await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, { inject: ['tools', 'systemPrompt'] })) diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index d4851cdbd7..49ffc0bac9 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -6,9 +6,11 @@ import type { Scope } from '@deepseek-ai/dsh-scope' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import type { PreToolDecision, ToolDefinition, ToolExecution, ToolExecutionInput, ToolExecutionToken } from '@deepseek-ai/dsh-tools' -import type { Agent, AgentId } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' + import { CallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SessionId } from '@deepseek-ai/dsh-session' /** Mount the registry (with its systemPrompt dependency) on a fresh context. */ async function mount(): Promise { @@ -20,7 +22,7 @@ async function mount(): Promise { /** Mint a scope whose key doubles as a minimal Agent-like object. */ async function mintAgentScope(ctx: Context, name: string): Promise<{ scope: Scope; key: Agent }> { - const key = { id: name as AgentId } as Agent + const key = { id: name as SessionId } as Agent let scope!: Scope // The scoped context resolves services through the MINTING plugin's // dependency chain — the minter must inject what scope holders will reach @@ -62,7 +64,7 @@ describe('scoped tool registration', () => { it('files a scoped tool in its layer: visible/executable for that scope only', async () => { const ctx = await mount() const { scope, key } = await mintAgentScope(ctx, 'a') - const other = { id: 'other' as AgentId } as Agent + const other = { id: 'other' as SessionId } as Agent ctx.tools.register(tool('shared')) scope.ctx.tools.register(tool('mine')) @@ -195,7 +197,7 @@ describe('scoped execution dispatch', () => { it('an agent.ctx pre-execute listener gates only its own agent (and never subject-less calls)', async () => { const ctx = await mount() const { scope, key } = await mintAgentScope(ctx, 'a') - const other = { id: 'other' as AgentId } as Agent + const other = { id: 'other' as SessionId } as Agent ctx.tools.register(tool('t')) const seen: (string | undefined)[] = [] @@ -213,7 +215,7 @@ describe('scoped execution dispatch', () => { it('applies scoped guards after pre-execute and unwinds duplicate registrations independently', async () => { const ctx = await mount() const { scope, key } = await mintAgentScope(ctx, 'a') - const other = { id: 'other' as AgentId } as Agent + const other = { id: 'other' as SessionId } as Agent let bodyCalls = 0 ctx.tools.register({ ...tool('t'), @@ -428,7 +430,7 @@ describe('scoped execution dispatch', () => { it('uses one input snapshot for the normalized error shell', async () => { const ctx = await mount() const { scope, key } = await mintAgentScope(ctx, 'accepted') - const driftAgent = { id: 'drift' as AgentId } as Agent + const driftAgent = { id: 'drift' as SessionId } as Agent ctx.tools.register(tool('parent')) ctx.tools.register(tool('t')) let parent!: ToolExecutionToken diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index caa6ffd582..6785e5f957 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -19,6 +19,7 @@ import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' export const name = 'acp-demo' +const DEFAULT_PERSISTENCE_ROOT = './.sessions' /** * App config: the swappable per-deployment values. `provider` and `model` configure the @@ -70,9 +71,7 @@ export const Config: z = z.object({ toolOrder: z.array(z.string()).default(undefined as unknown as string[]), tools: ToolRegistry.Config, dshHome: z.string(), - // TODO(single-default-literal): share this schema default and the defensive - // apply() fallback through one named constant while retaining both boundaries. - persistenceRoot: z.string().default('./.sessions'), + persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT), workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), skills: agentCore.SkillConfigSchema, toolBash: agentCore.ToolBashConfigSchema, @@ -90,6 +89,6 @@ export const Config: z = z.object({ export function apply(ctx: Context, config: Config): void { ctx.plugin(agentCore, agentCore.pickSpineConfig(config)) ctx.plugin(UserInteractionService) - ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' }) + ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT }) ctx.plugin(acp, { provider: config.provider, model: config.model }) } diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index c033056f12..10ee749f8c 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -83,7 +83,7 @@ describe('dsh-acp-demo composition', () => { }) it('defaults the persistence root when omitted', async () => { - // Exercises the `?? './.sessions'` fallback for a direct-apply caller that + // Exercises the `DEFAULT_PERSISTENCE_ROOT` fallback for a direct-apply caller that // bypasses the schema's `.default(...)`: call `apply` directly (not via // `ctx.plugin`, which validates+defaults the config first) with no // persistenceRoot, so the runtime fallback is the one that fires. diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index fdf17286ee..5d5b336ff9 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -6,7 +6,7 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import * as agentCore from '../src/index.ts' -import { AgentId, agentEvents, type Agent } from '@deepseek-ai/dsh-agent' +import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -86,10 +86,10 @@ async function withIsolatedSkillHomes(run: () => Promise): Promise { } } -function waitForMainIdle(ctx: Context): Promise { +function waitForIdle(ctx: Context, target: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (agent, status) => { - if (agent.id === 'main' && status === 'idle') { + if (agent === target && status === 'idle') { dispose() resolve() } @@ -129,17 +129,19 @@ describe('dsh-agent-spine-demo bundle', () => { it('defaults the agents list to empty (no pre-created agents)', async () => { const ctx = await mount({ workspaceContext: false }) - expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined() + expect(ctx.get('agents')?.get(SessionId('main'))).toBeUndefined() await ctx.fiber.dispose() }) it('forwards a pre-created agent to the loop and the persona to system-prompt', async () => { const ctx = await mount({ - agents: [{ id: AgentId('main'), provider: 'mock', model: 'mock' }], + agents: [{ id: SessionId('main'), provider: 'mock', model: 'mock' }], persona: 'You are main.', workspaceContext: false, }) - expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined() + const agent = ctx.get('agents')?.list()[0] + expect(agent?.id).toBe(agent?.session.id) + expect(agent?.id).toMatch(/^main-session-/) const assembly = await ctx.get('systemPrompt')!.assemble() expect(assembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You are main.') await ctx.fiber.dispose() @@ -147,7 +149,7 @@ describe('dsh-agent-spine-demo bundle', () => { it('forwards the global maxParallelToolCalls config to agent-loop', async () => { const ctx = await mount({ - agents: [{ id: AgentId('main'), provider: 'mock', model: 'mock' }], + agents: [{ id: SessionId('main'), provider: 'mock', model: 'mock' }], maxParallelToolCalls: 3, workspaceContext: false, }) @@ -178,7 +180,6 @@ describe('dsh-agent-spine-demo bundle', () => { await ctx.plugin(LocalFileSystem, { cwd: '/' }) ctx.llm.registerAdapter(['mock'], adapter) const handle = await ctx.agents.create({ - agentId: AgentId('main'), sessionId: SessionId('main-session'), meta: { cwd: root }, agentOptions: { provider: 'mock', model: 'mock' }, @@ -186,7 +187,7 @@ describe('dsh-agent-spine-demo bundle', () => { const agent = handle.agent agent.send([{ type: 'text', text: 'hi' }]) - await waitForMainIdle(ctx) + await waitForIdle(ctx, agent) const sentText = adapter.requests[0]?.messages.map(messageText).join('\n') expect(sentText).toContain('hi') @@ -209,14 +210,13 @@ describe('dsh-agent-spine-demo bundle', () => { const ctx = await mount({ workspaceContext: { maxBytes: 0 } }) ctx.llm.registerAdapter(['mock'], adapter) const handle = await ctx.agents.create({ - agentId: AgentId('main'), sessionId: SessionId('main-disabled-session'), meta: { cwd: root }, agentOptions: { provider: 'mock', model: 'mock' }, }) handle.agent.send([{ type: 'text', text: 'hi' }]) - await waitForMainIdle(ctx) + await waitForIdle(ctx, handle.agent) expect(adapter.requests[0]?.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'hi' }] }]) await handle.dispose() @@ -299,14 +299,13 @@ describe('dsh-agent-spine-demo bundle', () => { content: 'body', }) const handle = await ctx.agents.create({ - agentId: AgentId('main'), sessionId: SessionId('prefix-order-session'), meta: { cwd: root }, agentOptions: { provider: 'mock', model: 'mock' }, }) handle.agent.send([{ type: 'text', text: 'hi' }]) - await waitForMainIdle(ctx) + await waitForIdle(ctx, handle.agent) expect(messageText(adapter.requests[0]?.messages[0])).toContain('workspace rule before skills') expect(messageText(adapter.requests[0]?.messages[1])).toContain('prefix-order-skill') diff --git a/packages/examples/stdio-demo/README.md b/packages/examples/stdio-demo/README.md index 5eb6ea766c..b783ca0078 100644 --- a/packages/examples/stdio-demo/README.md +++ b/packages/examples/stdio-demo/README.md @@ -15,7 +15,7 @@ A terminal chat always wants the same cluster, so the package owns it rather tha | `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` | | `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools | | `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool | -| `@deepseek-ai/dsh-stdio` | the readline UI, bound to the `main` agent | +| `@deepseek-ai/dsh-stdio` | the readline UI, bound to the exact app-owned agent/session identity and rendering it as `main` | `@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`. @@ -39,7 +39,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte | `welcome` | `ready.` | the stdin-chat banner | | `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) | -Fresh stdio sessions use the process launch directory as `session.header.cwd`, so project-scoped features such as skill discovery and default bash workdir follow the directory where `dsh-stdio-demo` was started. Resumed sessions keep the cwd stored in the persisted session header. +Fresh stdio sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-` agent/session id, so durable restarts cannot collide. The app passes that exact opaque id to both its config-created agent and UI; an AgentLoop-only reload resumes materialized history under that id, while the UI's `main` text remains only a display label and never selects another registry root by prefix or insertion order. Readline buffers nonblank startup input for that identity until `agent/session-start`, so piped stdin cannot outrun asynchronous exact-id restoration or let EOF discard the queued prompt; `agent-loop/config-start-failed` instead drains and reports buffered input so a missing or corrupt persisted session cannot hang EOF. A resumed run binds both components to the exact `resumeSessionId` and keeps the cwd stored in the persisted session header. ## The bin diff --git a/packages/examples/stdio-demo/package.json b/packages/examples/stdio-demo/package.json index a523f61213..992b63e1af 100644 --- a/packages/examples/stdio-demo/package.json +++ b/packages/examples/stdio-demo/package.json @@ -35,6 +35,7 @@ "@deepseek-ai/dsh-app-boot": "^0.0.1", "@cordisjs/plugin-logger-console": "^1.0.0", "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-agent-loop": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-agent-spine-demo": "^0.0.1", "@deepseek-ai/dsh-workspace-context": "^0.0.1", @@ -53,6 +54,7 @@ "@deepseek-ai/dsh-app-boot": "workspace:^", "@cordisjs/plugin-logger-console": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/examples/stdio-demo/src/index.ts b/packages/examples/stdio-demo/src/index.ts index 91c377e9f7..0fb43cdff1 100644 --- a/packages/examples/stdio-demo/src/index.ts +++ b/packages/examples/stdio-demo/src/index.ts @@ -2,7 +2,8 @@ * The stdio chat app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}) plus the * coupled front-door cluster a terminal chat needs — a console logger, the independently * packaged readline UI, JSONL session persistence, the user-interaction seam with its - * `ask_user_question` tool, and a pre-created `main` agent the UI drives. + * `ask_user_question` tool, and one pre-created agent whose exact shared + * agent/session identity the UI drives under its `main` display label. * Swappable adapters, executors, optional tools, and HMR stay in the leaf. This * Loader plugin intentionally exposes named exports only; a default export * would hide its `Config` schema (see docs/postmortem/0001). @@ -10,9 +11,9 @@ */ import type { Context } from 'cordis' +import { randomUUID } from 'node:crypto' import ConsoleExporter from '@cordisjs/plugin-logger-console' import z from 'schemastery' -import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' @@ -23,6 +24,8 @@ import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user' import * as uiStdio from '@deepseek-ai/dsh-stdio' export const name = 'stdio-demo' +const DEFAULT_PERSISTENCE_ROOT = './.sessions' +const DEFAULT_WELCOME = 'ready.' /** * App config: the swappable per-demo values, each routed to where the app wires @@ -60,7 +63,7 @@ export interface Config { /** Generic background-task control-tool config forwarded through agent-core. */ toolTasks?: NonNullable /** - * If set, the `main` agent RESUMES this persisted session id instead of + * If set, the pre-created agent RESUMES this persisted session id instead of * starting fresh. Sourced from an env var in the leaf `cordis.yml` * (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`). */ @@ -80,10 +83,8 @@ export const Config: z = z.object({ toolOrder: z.array(z.string()).default(undefined as unknown as string[]), tools: ToolRegistry.Config, dshHome: z.string(), - // TODO(single-default-literal): share these schema defaults and defensive - // apply() fallbacks through named constants while retaining both boundaries. - persistenceRoot: z.string().default('./.sessions'), - welcome: z.string().default('ready.'), + persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT), + welcome: z.string().default(DEFAULT_WELCOME), skills: agentCore.SkillConfigSchema, toolBash: agentCore.ToolBashConfigSchema, toolTasks: agentCore.ToolTasksConfigSchema, @@ -92,26 +93,32 @@ export const Config: z = z.object({ }) /** - * Compose the spine with the stdio front door. The console logger comes first - * (infra), then the agent-spine-demo bundle pre-creating the `main` agent from this - * app's `model`/`resumeSessionId` with the deployment `persona`, then the JSONL - * backend, then the readline UI bound to `main`. The `hmr` dev-reload plugin is - * a leaf concern (see the module doc), so it is not mounted here. + * Compose the spine with the stdio front door. Console logging, persistence, + * and user interaction mount first; the readline UI then waits on the agent + * registry and subscribes to config-start failures before agent-core can start + * the configured identity. The ask-user tool waits on the completed spine. + * The `hmr` dev-reload plugin is a leaf concern (see the module doc), so it is + * not mounted here. */ export function apply(ctx: Context, config: Config): void { + const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId + const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`) ctx.plugin(ConsoleExporter) + ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT }) + ctx.plugin(UserInteractionService) + ctx.plugin(uiStdio, { + welcome: config.welcome ?? DEFAULT_WELCOME, + sessionId, + }) ctx.plugin(agentCore, { ...agentCore.pickSpineConfig(config), agents: [{ - id: AgentId('main'), + id: SessionId('main'), provider: config.provider, model: config.model, cwd: process.cwd(), - ...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {}, + ...resumeSessionId === undefined ? { sessionId } : { resumeSessionId: sessionId }, }], }) - ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' }) - ctx.plugin(UserInteractionService) ctx.plugin(toolAskUser) - ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.', agent: 'main' }) } diff --git a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts index 4c81eb93e4..7ef169ff06 100644 --- a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts +++ b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts @@ -4,7 +4,8 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import { AgentId, agentEvents, type Agent } from '@deepseek-ai/dsh-agent' +import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' + import type { Message } from '@deepseek-ai/dsh-llm' import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import * as stdioAgent from '../src/index.ts' @@ -73,24 +74,44 @@ describe('dsh-stdio-demo app', () => { expect(ctx.get('sessionPersistence')).toBeDefined() expect(ctx.get('userInteraction')).toBeDefined() expect(ctx.get('tools')?.get('ask_user_question')).toBeDefined() - // The pre-created `main` agent the UI drives. - const agent = ctx.get('agents')?.get(AgentId('main')) + // The sole pre-created agent the UI drives. `main` is its stable config + // label; each fresh process mints a durable combined agent/session id. + await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1) + const agent = ctx.get('agents')?.list()[0] expect(agent).toBeDefined() + expect(agent?.id).toBe(agent?.session.id) + expect(agent?.id).toMatch(/^main-session-/) expect(agent?.session.header.cwd).toBe(process.cwd()) await ctx.fiber.dispose() }) + it('normalizes an empty resume id to a fresh exact app identity', async () => { + const ctx = await mount({ + provider: 'mock', + model: 'mock', + resumeSessionId: '', + persistenceRoot: '/tmp/dsh-stdio-agent-spec-empty-resume', + skills: await isolatedSkillsConfig(), + workspaceContext: false, + }) + await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1) + const agent = ctx.get('agents')?.list()[0] + expect(agent?.id).toMatch(/^main-session-[0-9a-f-]{36}$/) + expect(agent?.id).toBe(agent?.session.id) + await ctx.fiber.dispose() + }) + it('defaults persistenceRoot and welcome when omitted', async () => { // Direct apply (NOT via ctx.plugin, which validates+defaults the config - // first) so the runtime `?? './.sessions'` / `?? 'ready.'` fallbacks on + // first) so the runtime `DEFAULT_PERSISTENCE_ROOT` / `DEFAULT_WELCOME` fallbacks on // apply()'s last two lines are the ones that fire — covering a // schema-bypassing direct-mount caller. const ctx = new Context() // No persona: covers the omitted-persona forwarding branch too. stdioAgent.apply(ctx, { provider: 'mock', model: 'mock', skills: await isolatedSkillsConfig(), workspaceContext: false }) - await new Promise(resolve => setTimeout(resolve, 80)) + await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1) expect(ctx.get('sessionPersistence')).toBeDefined() - expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined() + expect(ctx.get('agents')?.list()[0]?.id).toMatch(/^main-session-/) await ctx.fiber.dispose() }) @@ -102,7 +123,8 @@ describe('dsh-stdio-demo app', () => { persistenceRoot: '/tmp/dsh-stdio-demo-spec-workspace-context', workspaceContext: false, }) - expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined() + await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1) + expect(ctx.get('agents')?.list()[0]?.id).toMatch(/^main-session-/) await ctx.fiber.dispose() }) @@ -119,7 +141,7 @@ describe('dsh-stdio-demo app', () => { it('forwards resumeSessionId onto the pre-created agent when set', async () => { // A resume id defers agent creation until persistence loads; with no backing - // session the resume is contained + logged, so no `main` agent registers — + // session the resume is contained + logged, so no agent registers — // the branch that maps resumeSessionId through is what this covers. const ctx = await mount({ provider: 'mock', @@ -130,7 +152,7 @@ describe('dsh-stdio-demo app', () => { skills: await isolatedSkillsConfig(), workspaceContext: false, }) - expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined() + expect(ctx.get('agents')?.list()).toEqual([]) await ctx.fiber.dispose() }) diff --git a/packages/fs/tool-fs/tests/fs-tools.e2e.ts b/packages/fs/tool-fs/tests/fs-tools.e2e.ts index ca93bad63d..472b32f47a 100644 --- a/packages/fs/tool-fs/tests/fs-tools.e2e.ts +++ b/packages/fs/tool-fs/tests/fs-tools.e2e.ts @@ -3,7 +3,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import { fsHarness, waitForIdle } from './harness.ts' @@ -35,7 +34,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () => ctx = await fsHarness(workdir, SYSTEM) // agentLoop.create prepares a session with no cwd, so the provider default // (config.cwd = workdir) is the workspace. - const agent = ctx.agentLoop.create(AgentId('fs-e2e'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('fs-e2e'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', text: 'Create a file named note.txt containing exactly the line: status: draft. ' @@ -65,7 +64,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () => try { ctx = await fsHarness(configDir, SYSTEM) const handle = await ctx.agents.create({ - agentId: AgentId('fs-e2e-cwd'), sessionId: SessionId(`fs-e2e-cwd-${Date.now()}`), meta: { cwd: sessionDir }, agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, diff --git a/packages/guard/repeat-tool-guard/README.md b/packages/guard/repeat-tool-guard/README.md index 5a9fed247f..2927e3f20a 100644 --- a/packages/guard/repeat-tool-guard/README.md +++ b/packages/guard/repeat-tool-guard/README.md @@ -24,8 +24,8 @@ The chain key is `(tool name, canonical arguments)` — canonicalization is a de - **Untracked calls are transparent to the chain.** A call excluded by `include`/`exclude` neither increments nor resets the counter, so `grep X → todo_write → grep X` still counts as two consecutive `grep X` when `todo_write` is excluded. This is what makes exclusion useful: bookkeeping tools interleaved into a loop must not launder it. - **Denied calls count.** Detection sits on `tools/post-execute`, which also runs for calls a `tools/pre-execute` listener denied — a model hammering a denied call is exactly the loop worth breaking. -- **Calls without an agent are ignored.** A direct `ctx.tools.execute()` caller has no model to remind and no `AgentId` to key on. -- **Per-agent keying.** The tool registry is context-level and subagents interleave through the same waterfall, so chains are keyed by `AgentId`; one agent's repetition never trips another's reminder. A user prompt (`agent/prompt-submit`) resets the submitting agent's chain; agent disposal drops its state. +- **Calls without an agent are ignored.** A direct `ctx.tools.execute()` caller has no model to remind and no live agent object to key on. +- **Per-agent keying.** The tool registry is context-level and subagents interleave through the same waterfall, so a `WeakMap` keys each chain by the live agent object; one agent's repetition never trips another's reminder. A user prompt (`agent/prompt-submit`) resets the submitting agent's chain, and object lifetime bounds the weak entry without a disposal listener. - **In-memory only.** A session resumed from persistence starts with a fresh chain — the guard is a heuristic nudge, not a logged invariant, later reminders are the accepted cost. ## Reminder delivery diff --git a/packages/guard/repeat-tool-guard/src/index.ts b/packages/guard/repeat-tool-guard/src/index.ts index 9df8dd399f..2279f53ade 100644 --- a/packages/guard/repeat-tool-guard/src/index.ts +++ b/packages/guard/repeat-tool-guard/src/index.ts @@ -1,15 +1,14 @@ /** - * Advisory repeat-call loop breaker. It never registers, blocks, or rewrites a tool; configured - * consecutive canonical calls add source-attributed context after downstream post-policy. The - * loop logs that model-visible reminder as reconstructable context. Counters are per agent and - * in-memory, so one agent cannot trip another and resumed sessions start fresh. Named exports - * preserve loader metadata. See the package README for chain semantics and thresholds. + * Advisory per-agent repeat-call detector. It enriches post-execute decisions + * with logged model context without vetoing or rewriting calls. Configuration + * and chain semantics live in the package README; rationale lives in the + * repeat-tool-guard RFC. * @module @deepseek-ai/dsh-repeat-tool-guard */ import type { Context } from 'cordis' import z from 'schemastery' -import type { AgentId, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent' +import type { Agent, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent' import type { MessageSource } from '@deepseek-ai/dsh-llm' import type { PostToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools' @@ -169,9 +168,7 @@ export function apply(ctx: Context, config: Config): void { throw new Error(`repeat-tool-guard: invalid argumentsPreviewChars ${argumentsPreviewChars} — must be an integer >= 1`) } - // TODO(agent-keyed-repeat-chain): key a WeakMap by the Agent itself; that - // removes the disposal-only status listener and cannot collide on id reuse. - const chains = new Map() + const chains = new WeakMap() /** Whether a tool participates in the chain (untracked calls are transparent: they neither count nor reset). */ function tracked(toolName: string): boolean { @@ -194,9 +191,9 @@ export function apply(ctx: Context, config: Config): void { if (!tracked(exec.name)) return undefined const canonical = canonicalize(exec.arguments) const key = JSON.stringify([exec.name, canonical]) - const chain = chains.get(exec.agent.id) + const chain = chains.get(exec.agent) const count = chain !== undefined && chain.key === key ? chain.count + 1 : 1 - chains.set(exec.agent.id, { key, count }) + chains.set(exec.agent, { key, count }) if (!thresholdSet.has(count)) return undefined const text = count === thresholds[0] ? GENTLE_REMINDER @@ -226,12 +223,7 @@ export function apply(ctx: Context, config: Config): void { // loop. Pure reset hook: always delegates (attaching nothing, vetoing // nothing). ctx.on('agent/prompt-submit', (agent, _content, _source, next): Promise => { - chains.delete(agent.id) + chains.delete(agent) return next() }) - - // Drop state when an agent goes away, bounding the map over harness lifetime. - ctx.on('agent/status', (agent, status) => { - if (status === 'disposed') chains.delete(agent.id) - }) } diff --git a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts index 3c5c9051bf..101c542d5a 100644 --- a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts +++ b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import { defineTool } from '@deepseek-ai/dsh-tools' -import { AgentId } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as RepeatToolGuard from '@deepseek-ai/dsh-repeat-tool-guard' import type { Config } from '@deepseek-ai/dsh-repeat-tool-guard' @@ -29,12 +29,12 @@ async function harness(config: Config = {}): Promise { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) }) } /** Every `context/message` in the agent's log, flattened to joined text + source for terse assertions. */ -function reminders(agent: ReactLoopAgent): { text: string; source: unknown }[] { +function reminders(agent: Agent): { text: string; source: unknown }[] { return [...agent.session.events] .filter((e): e is SessionEvent<'context/message'> => e.type === 'context/message') .map(e => ({ @@ -53,7 +53,7 @@ describe('threshold escalation', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -74,7 +74,7 @@ describe('threshold escalation', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -96,7 +96,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -120,7 +120,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -138,7 +138,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -159,7 +159,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -175,7 +175,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -191,7 +191,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -211,8 +211,8 @@ describe('chain semantics', () => { toolCallResponse('b3', 'probe', { q: 1 }), textResponse('done'), ])) - const agentA = ctx.agentLoop.create(AgentId('a'), { provider: 'mock-a', model: 'model-a' }) - const agentB = ctx.agentLoop.create(AgentId('b'), { provider: 'mock-b', model: 'model-b' }) + const agentA = ctx.agentLoop.create(SessionId('a'), { provider: 'mock-a', model: 'model-a' }) + const agentB = ctx.agentLoop.create(SessionId('b'), { provider: 'mock-b', model: 'model-b' }) agentA.send([{ type: 'text', text: 'go' }]) agentB.send([{ type: 'text', text: 'go' }]) await Promise.all([waitForIdle(ctx, agentA), waitForIdle(ctx, agentB)]) @@ -231,7 +231,7 @@ describe('chain semantics', () => { textResponse('turn two done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) agent.send([{ type: 'text', text: 'again' }]) @@ -250,16 +250,16 @@ describe('chain semantics', () => { ])) // Loop agents are torn down by disposing the scope that created them // (the loop.spec pattern): a child plugin fiber owns `first`. - let first!: ReactLoopAgent + let first!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - first = inner.agentLoop.create(AgentId('reused'), { provider: 'mock', model: 'mock' }) + first = inner.agentLoop.create(SessionId('reused'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) first.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, first) await fiber.dispose() - await first.done + await first.whenIdle() - const second = ctx.agentLoop.create(AgentId('reused'), { provider: 'mock', model: 'mock' }) + const second = ctx.agentLoop.create(SessionId('reused'), { provider: 'mock', model: 'mock' }) second.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, second) @@ -275,7 +275,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -291,7 +291,7 @@ describe('chain semantics', () => { toolCallResponse('c1', 'probe', { q: 1 }), // if the direct call had counted, this would be #2 textResponse('done'), ])) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -313,7 +313,7 @@ describe('fold onto the downstream decision', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -344,7 +344,7 @@ describe('fold onto the downstream decision', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 672a89188a..65dd29d146 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -4,18 +4,22 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context, type Fiber } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import { defineTool } from '@deepseek-ai/dsh-tools' -import { AgentId } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import { SubagentRunId } from '@deepseek-ai/dsh-subagent' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' /** - * Full-loop Claude bridge tests with a mock model, the real loop and bash - * executor, and shell hooks from a temporary config. + * Full-loop bridge tests: a scripted mock MODEL drives the REAL agent loop + REAL + * bash executor, and the REAL `dsh-hooks-claude` bridge runs REAL shell hook + * scripts written to a temp dir — only the model is mocked (the "prefer the real + * implementation" rule). Each test writes a `hooks.json` + executable scripts, + * loads the bridge pointed at them, and asserts the hook's effect on the loop. */ const dirs: string[] = [] @@ -49,7 +53,7 @@ async function harnessWithFiber(configDir: string, adapter: MockAdapter): Promis return { ctx, hooks } } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { dispose(); resolve() } @@ -57,7 +61,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } -function events(agent: ReactLoopAgent): SessionEvent[] { +function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } @@ -87,7 +91,7 @@ describe('hooks-claude bridge — UserPromptSubmit', () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(dir, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'do something' }]) await waitForIdle(ctx, agent) @@ -110,7 +114,7 @@ describe('hooks-claude bridge — UserPromptSubmit', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(dir, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -135,7 +139,7 @@ describe('hooks-claude bridge — PreToolUse', () => { const ctx = await harness(dir, adapter) let ran = false ctx.tools.register(defineTool({ name: 'danger', description: 'd', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'use danger' }]) await waitForIdle(ctx, agent) @@ -158,7 +162,7 @@ describe('hooks-claude bridge — PreToolUse', () => { const ctx = await harness(dir, adapter) let ran = false ctx.tools.register(defineTool({ name: 'safe', description: 's', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ran ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'use safe' }]) await waitForIdle(ctx, agent) @@ -180,11 +184,12 @@ describe('hooks-claude bridge — PostToolUse', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(dir, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'raw output' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') + // PostToolUse blocks AFTER the tool ran: the result is rewritten to isError + feedback. expect(result?.type === 'tool/result' && result.data.isError).toBe(true) expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('output rejected, retry'))).toBe(true) }) @@ -200,7 +205,7 @@ describe('hooks-claude bridge — PostToolUse', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(dir, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -224,7 +229,7 @@ describe('hooks-claude bridge — PostToolUse', () => { const ctx = await harness(dir, adapter) let ran = false ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -248,7 +253,7 @@ describe('hooks-claude bridge — SessionStart', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(dir, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // session-start fires async (detached .then → agent.inject); wait for the // injected context/message to actually land before sending, rather than a // fixed sleep that flakes under load. @@ -284,17 +289,21 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () => const { ctx, hooks } = await harnessWithFiber(dir, adapter) // Drive the observe-only lifecycle events directly (no real child needed — the // bridge just listens). No child agent is registered, so SubagentStart's - // child lookup yields undefined and it runs the hook. - ctx.emit('subagent/start', { provider: 'inproc', id: AgentId('child-1') }) - ctx.emit('subagent/end', { provider: 'inproc', id: AgentId('child-1'), stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] }) + // child lookup yields undefined and it simply runs the hook. + ctx.emit('subagent/start', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false }) + ctx.emit('subagent/end', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] }) // Both hooks run async (detached .then); poll for their marker files rather // than a fixed sleep that flakes under load. await waitFor(() => existsSync(startMarker) && existsSync(stopMarker)) expect(existsSync(startMarker)).toBe(true) expect(existsSync(stopMarker)).toBe(true) - // A marker proves only that the process ran. Disposal drains its detached continuation so the - // no-context branch completes before the per-file coverage snapshot instead of racing CI. + // The markers prove the hook PROCESSES ran, not that the detached `.then` + // continuations did (`touch` lands before the process exits). Dispose drains + // them, so the no-context arm of the SubagentStart continuation — covered + // only here — executes before this file's coverage snapshot instead of + // racing it (the arm went uncovered on a loaded CI runner and failed the + // per-file 100% branch gate). await hooks.dispose() }) @@ -304,8 +313,10 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () => const pidFile = join(dir, 'pid') const marker = join(dir, 'started') const slowHook = join(dir, 'slow.sh') - // Record the PID and marker before sleeping past the suite timeout. Disposal must abort and - // kill the process rather than await its exit or the default ten-minute hook timeout. + // Record the hook shell's PID and touch the marker FIRST so the test can + // tell "the hook is genuinely mid-run", then sleep far past the suite + // timeout. Dispose must KILL the process (the tracker's abort signal), not + // await its exit or its 10-minute default hook timeout. writeFileSync(slowHook, `#!/usr/bin/env bash\necho $$ > "${pidFile}"\ntouch "${marker}"\nsleep 30\n`) chmodSync(slowHook, 0o755) writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { @@ -315,15 +326,18 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () => const { ctx, hooks } = await harnessWithFiber(dir, new MockAdapter([])) const warn = vi.fn() ctx.logger.warn = warn as never - ctx.emit('subagent/start', { provider: 'inproc', id: AgentId('child-1') }) + ctx.emit('subagent/start', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false }) await waitFor(() => existsSync(marker)) const pid = Number(readFileSync(pidFile, 'utf8').trim()) await hooks.dispose() - // Disposal reaches quiescence: it returns only after the aborted run settles and the process - // is reaped, so `kill(pid, 0)` must report ESRCH. Untracked fire-and-forget work would remain. + // Quiescence, not just promptness: the drain resolves only after the run + // settled, and the run settles only after the killed process was reaped — + // so by the time dispose returns, the PID must be GONE (kill(pid, 0) + // throws ESRCH). An untracked fire-and-forget regression would leave the + // process alive (or unreaped) and fail this deterministically. expect(() => process.kill(pid, 0)).toThrow() - // runHook resolves an aborted run as a non-blocking error, so draining must - // not log a rejected continuation. + // The aborted run resolves as a non-blocking error (runHook never rejects), + // so the drained continuation must NOT have logged a failure. expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed')) }) }) @@ -337,7 +351,7 @@ describe('hooks-claude bridge — load resilience', () => { await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(HooksClaude, { configPath: '/nonexistent/hooks.json' }) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // The turn ran normally — no hooks, no crash. @@ -345,8 +359,11 @@ describe('hooks-claude bridge — load resilience', () => { }) it('disposing the bridge fiber removes its listeners (HMR safety)', async () => { - // This is the only bridge mount, and its blocking hook would veto the prompt and log an event - // if its listener leaked after disposal. A no-op hook would not expose that leak. + // A BLOCKING UserPromptSubmit hook: if the listener leaked past dispose it + // would veto the prompt (0 model requests) and log a hook/invoked. Build the + // ctx WITHOUT the harness's own bridge mount so this is the ONLY mount, then + // dispose it — a leaked listener fails the test (a no-op `true` hook would + // pass even leaked, so it proved nothing). const dir = writeConfig({ UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = new Context() @@ -356,7 +373,7 @@ describe('hooks-claude bridge — load resilience', () => { const fiber = await ctx.plugin(HooksClaude, { configPath: join(dir, 'hooks.json') }) await fiber.dispose() ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone @@ -364,8 +381,10 @@ describe('hooks-claude bridge — load resilience', () => { }) it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/apply', () => { - // A default export would make `unwrapExports` collapse the namespace and drop `inject`, causing - // load to fail. Guard the shape from postmortem 0001 directly. + // Postmortem 0001 guard: this plugin HAS `inject = ['bash']`, so a stray + // `export default apply` would collapse the module via `unwrapExports` + // (`exports.default ?? exports`), DROP `inject`, and crash at load with + // "cannot get property … without inject". Guard the shape directly. expect('default' in HooksClaude).toBe(false) expect(HooksClaude.name).toBe('hooks-claude') expect(HooksClaude.inject).toEqual(['bash']) diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts index 774e2a8451..ec7d18b4c4 100644 --- a/packages/hooks/hooks-claude/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -3,13 +3,14 @@ import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync, readFileSync import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from 'cordis' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import { defineTool } from '@deepseek-ai/dsh-tools' -import { AgentId } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import { SubagentRunId } from '@deepseek-ai/dsh-subagent' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -38,10 +39,10 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp ctx.llm.registerAdapter(['mock'], adapter) return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) }) } -function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] } +function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } /** Poll until `predicate` holds or the deadline passes — robust to detached * emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { @@ -65,7 +66,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} }) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('transcript'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) return { @@ -95,7 +96,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter, { pluginRoot: d, projectDir: d }) ctx.logger.warn = warn as never ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(existsSync(marker)).toBe(true) // substituted command ran @@ -111,7 +112,7 @@ export function defineCoverageCases(group: CoverageGroup): void { ctx.logger.warn = warn as never let sawArgs: unknown ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: { command: { type: 'string' } }, async execute(args) { sawArgs = args; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // updatedInput is NOT honored — the tool ran with the ORIGINAL args. @@ -127,7 +128,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('ran')]) const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // The prompt proceeded unchanged; no context/message injected. @@ -157,7 +158,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -182,7 +183,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 }) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -198,7 +199,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) @@ -214,7 +215,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // A second model request ran → the empty-reason block forced continuation. @@ -230,9 +231,9 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, new MockAdapter([])) // Register a fake child agent under the id the event carries. const injected: string[] = [] - const child = { id: AgentId('child-x'), inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: { header: { id: 'child-x' } } } as unknown as Parameters[0] + const child = { id: SessionId('child-x'), inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: { id: SessionId('child-x'), header: { id: 'child-x' } } } as unknown as Parameters[0] ctx.agents.register(child) - ctx.emit('subagent/start', { provider: 'p', id: AgentId('child-x') }) + ctx.emit('subagent/start', { runId: SubagentRunId('run-x'), provider: 'p', id: SessionId('child-x'), local: true }) await waitFor(() => injected.includes('child guidance')) expect(injected).toContain('child guidance') }) @@ -246,9 +247,9 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { SubagentStart: [{ hooks: [{ type: 'command', command: s }] }] }) const ctx = await harness(path, new MockAdapter([])) const warn = vi.fn(); ctx.logger.warn = warn as never - const child = { id: AgentId('child-y'), inject: () => { throw new Error('inject boom') }, session: { header: { id: 'child-y' } } } as unknown as Parameters[0] + const child = { id: SessionId('child-y'), inject: () => { throw new Error('inject boom') }, session: { id: SessionId('child-y'), header: { id: 'child-y' } } } as unknown as Parameters[0] ctx.agents.register(child) - ctx.emit('subagent/start', { provider: 'p', id: AgentId('child-y') }) + ctx.emit('subagent/start', { runId: SubagentRunId('run-y'), provider: 'p', id: SessionId('child-y'), local: true }) await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SubagentStart hook failed'))) expect(warn).toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed')) }) @@ -262,7 +263,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'x' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -276,7 +277,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -292,7 +293,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) const path = hooks(d, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] }) const ctx = await harness(path, new MockAdapter([])) - ctx.emit('subagent/end', { provider: 'p', id: AgentId('child-z'), stopReason: 'completed' }) + ctx.emit('subagent/end', { runId: SubagentRunId('run-z'), provider: 'p', id: SessionId('child-z'), local: false, stopReason: 'completed' }) await waitFor(() => existsSync(marker)) expect(existsSync(marker)).toBe(true) }) @@ -305,7 +306,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('no')]) const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const turnEnd = events(agent).findLast(e => e.type === 'turn/end') @@ -320,7 +321,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) let ran = false ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // ask (no reason) → degrades to deny with the registry's generic message. @@ -335,7 +336,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -360,7 +361,7 @@ export function defineCoverageCases(group: CoverageGroup): void { // the protocol lib's reference default, not a config knob). HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(existsSync(marker)).toBe(true) @@ -375,7 +376,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) let ran = false ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(ran).toBe(true) @@ -390,7 +391,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -409,7 +410,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) let ran = false ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -426,7 +427,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -446,7 +447,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) let ran = false ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(ran).toBe(true) // the mismatched deny was discarded → the tool ran @@ -464,10 +465,10 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) // NB: no projectDir // The factory create() path honors meta.cwd (the plain agentLoop.create() does not). const { SessionId } = await import('@deepseek-ai/dsh-session') - const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { provider: 'mock', model: 'mock' } }) + const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { provider: 'mock', model: 'mock' } }) handle.agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, handle.agent as ReactLoopAgent) - expect(events(handle.agent as ReactLoopAgent).some(e => e.type === 'context/message' + await waitForIdle(ctx, handle.agent) + expect(events(handle.agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes(`dir=${workspace}`)))).toBe(true) await handle.dispose() }) @@ -481,9 +482,8 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(path, adapter) // A later listener that blocks every prompt (registered AFTER the bridge). - const { AgentId: AId } = await import('@deepseek-ai/dsh-agent') ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' })) - const agent = ctx.agentLoop.create(AId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // the downstream block won: the model was never called, no user/message was @@ -512,7 +512,7 @@ export function defineCoverageCases(group: CoverageGroup): void { meta: { owner: 'policy' }, }], })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const req = JSON.stringify(adapter.requests[0]!.messages) @@ -541,7 +541,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'rewritten-result' }] })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -565,7 +565,7 @@ export function defineCoverageCases(group: CoverageGroup): void { meta: { owner: 'policy' }, }], })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -589,7 +589,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -613,7 +613,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const bash = ctx.bash bash.run = (() => Promise.reject(new Error('executor down'))) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -629,7 +629,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // Make inject throw, forcing the SessionStart .catch path. const original = agent.inject.bind(agent) let threw = false @@ -663,9 +663,9 @@ export function defineCoverageCases(group: CoverageGroup): void { ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const { SessionId } = await import('@deepseek-ai/dsh-session') - const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } }) + const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } }) handle.agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, handle.agent as ReactLoopAgent) + await waitForIdle(ctx, handle.agent) expect(existsSync(marker)).toBe(true) // the marker landed in the SESSION dir const { readFileSync } = await import('node:fs') @@ -692,8 +692,8 @@ export function defineCoverageCases(group: CoverageGroup): void { // Register a live child on its own session cwd; emit subagent/end with its id. const { SessionId } = await import('@deepseek-ai/dsh-session') - const childHandle = await ctx.agents.create({ agentId: AgentId('child-stop'), sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { provider: 'mock', model: 'mock' } }) - ctx.emit('subagent/end', { provider: 'inproc', id: childHandle.agent.id, stopReason: 'completed' }) + const childHandle = await ctx.agents.create({ sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { provider: 'mock', model: 'mock' } }) + ctx.emit('subagent/end', { runId: SubagentRunId('run-stop'), provider: 'inproc', id: childHandle.agent.id, local: true, stopReason: 'completed' }) await waitFor(() => existsSync(marker)) expect(existsSync(marker)).toBe(true) // the marker landed in the CHILD dir @@ -713,7 +713,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(path, adapter) const warn = vi.fn(); ctx.logger.warn = warn as never - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage')) @@ -731,7 +731,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // Send immediately — do NOT wait for the session-start inject. agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index e686b565f2..2cb4cb0bc5 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -4,10 +4,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import { defineTool } from '@deepseek-ai/dsh-tools' -import { AgentId } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' @@ -47,14 +47,14 @@ async function harness(dir: string, adapter: MockAdapter): Promise { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { dispose(); resolve() } }) }) } -function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] } +function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } /** Poll `predicate` until true or the deadline passes (detached hook effects can't be awaited directly). */ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { @@ -76,7 +76,7 @@ describe('hooks-codex bridge', () => { const ctx = await harness(dir, adapter) let ran = false ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'no' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'run ls' }]) await waitForIdle(ctx, agent) @@ -97,7 +97,7 @@ describe('hooks-codex bridge', () => { const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer after goal')]) const ctx = await harness(dir, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -112,7 +112,7 @@ describe('hooks-codex bridge', () => { const adapter = new MockAdapter([textResponse('fine')]) const ctx = await harness(dir, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) @@ -122,7 +122,7 @@ describe('hooks-codex bridge', () => { const dir = configDir() // no hooks.json written const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(dir, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) @@ -142,7 +142,7 @@ describe('hooks-codex bridge', () => { const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' }) await fiber.dispose() ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone @@ -165,7 +165,7 @@ describe('hooks-codex bridge', () => { ctx.llm.registerAdapter(['mock'], new MockAdapter([])) const warn = vi.fn() ctx.logger.warn = warn as never - ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) // fires agent/session-start + ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // fires agent/session-start await waitFor(() => existsSync(marker)) const pid = Number(readFileSync(pidFile, 'utf8').trim()) await fiber.dispose() diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index d11c74951d..a7f2e1e0ac 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -3,11 +3,11 @@ import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync, readFileSync import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from 'cordis' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import { defineTool } from '@deepseek-ai/dsh-tools' -import { AgentId } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' @@ -34,10 +34,10 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp ctx.llm.registerAdapter(['mock'], adapter) return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) }) } -function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] } +function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } /** Poll until `predicate` holds or the deadline passes — robust to detached * emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { @@ -62,7 +62,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} }) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('transcript'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) return { @@ -81,7 +81,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) const adapter = new MockAdapter([textResponse('no')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) const te = events(agent).findLast(e => e.type === 'turn/end') @@ -93,7 +93,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"ctx-x"}}\'\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('ctx-x') }) @@ -106,7 +106,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) expect(events(agent).some(e => e.type === 'user/message')).toBe(false) @@ -129,7 +129,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro meta: { owner: 'policy' }, }], })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const req = JSON.stringify(adapter.requests[0]!.messages) expect(req).toContain('from-bridge') @@ -153,7 +153,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'rewritten-result' }] })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true) @@ -175,7 +175,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro meta: { owner: 'policy' }, }], })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const contexts = events(agent).filter(event => event.type === 'context/message') @@ -194,7 +194,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.isError).toBe(true) @@ -207,7 +207,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"start-ctx"}}\'\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) await waitFor(() => events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('start-ctx')))) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) @@ -220,7 +220,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const r = events(agent).find(e => e.type === 'tool/result') expect(r?.type === 'tool/result' && r.data.isError).toBe(true) @@ -233,7 +233,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true) }) @@ -247,7 +247,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) // clean-exit hook allows; commandOf returned '' }) @@ -258,7 +258,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0) @@ -271,7 +271,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true) @@ -294,7 +294,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: 40 }) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…') @@ -317,7 +317,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro // Direct apply (schema bypass) → the `model ?? ''` fallback is exercised. HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(existsSync(marker)).toBe(true) expect(warn).toHaveBeenCalledWith(expect.stringContaining('async hook')) @@ -330,7 +330,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) }) @@ -344,7 +344,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', `#!/usr/bin/env bash\ntouch "${marker}"\nexit 0\n`) }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) await waitFor(() => existsSync(marker)) // the clean no-output hook has finished agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(events(agent).some(e => e.type === 'context/message')).toBe(false) @@ -356,7 +356,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const warn = vi.fn(); ctx.logger.warn = warn as never - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.inject = (() => { throw new Error('inject boom') }) await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SessionStart hook failed'))) expect(warn).toHaveBeenCalledWith(expect.stringContaining('SessionStart hook failed')) @@ -371,7 +371,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) }) @@ -384,7 +384,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) // matcher didn't match → no hook ran → tool proceeded expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) @@ -400,7 +400,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded @@ -413,7 +413,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const r = events(agent).find(e => e.type === 'tool/result') expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true) @@ -425,7 +425,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const r = events(agent).find(e => e.type === 'tool/result') expect(r?.type === 'tool/result' && r.data.isError).toBe(true) @@ -442,7 +442,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 7 }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'number' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_input: { command: string } } expect(payload.tool_input.command).toBe('') @@ -478,7 +478,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.bash.run = (() => Promise.reject(new Error('executor down'))) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false) @@ -494,7 +494,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { Stop: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\nexit 2\n`) }] }] }) const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) // empty-reason block forced continuation expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('blocked by Stop hook') @@ -507,7 +507,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho "extra guidance from a plain hook"\nexit 0\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('extra guidance from a plain hook') }) @@ -521,7 +521,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', `#!/usr/bin/env bash\ntouch "${marker}"\necho "stale"\nexit 2\n`) }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) await waitFor(() => existsSync(marker)) // the exit-2 hook has finished expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('stale')))).toBe(false) @@ -535,7 +535,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'e.sh', '#!/usr/bin/env bash\necho "stale"\nexit 1\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) // exit 1 is non-blocking → the turn ran expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('stale') @@ -546,7 +546,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'ss.sh', '#!/usr/bin/env bash\necho "session preamble"\nexit 0\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) await waitFor(() => events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('session preamble')))) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) @@ -560,7 +560,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'j.sh', '#!/usr/bin/env bash\necho \'{"unrelated":"json"}\'\nexit 0\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('unrelated') }) @@ -575,7 +575,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'shell', { command: 'ls' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_name: string; tool_input: { command: string } } expect(payload.tool_name).toBe('shell') @@ -591,7 +591,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false ctx.tools.register(defineTool({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(false) // the matcher fired → the hook denied the tool expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.point === 'PreToolUse')).toBe(true) @@ -603,7 +603,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const warn = vi.fn(); ctx.logger.warn = warn as never - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage')) expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up') @@ -626,9 +626,9 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro ctx.llm.registerAdapter(['mock'], adapter) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const { SessionId } = await import('@deepseek-ai/dsh-session') - const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } }) + const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } }) handle.agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, handle.agent as ReactLoopAgent) + await waitForIdle(ctx, handle.agent) expect(existsSync(marker)).toBe(true) expect(readFileSync(marker, 'utf8').trim().endsWith(sessionDir.split('/').pop()!)).toBe(true) await handle.dispose() diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 7da98cdebf..a4b43342e4 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -4,6 +4,8 @@ DeepSeek chat-completions adapter for the harness LLM seam: hand-rolled `fetch` A second, library-backed implementation of the same seam exists in `@deepseek-ai/dsh-llm-pi-ai`. This package always owns the `deepseek` provider route; mounting a pi-ai profile with `provider: deepseek` in the same context throws `LlmError('DUPLICATE_ADAPTER')` by design. +The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire serialization, SSE parsing, and chunk translation helpers are not part of that root contract. + ## Config ```yaml diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index ece053c6b3..5fa01ce503 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -98,10 +98,10 @@ export class DeepSeekAdapter extends LlmAdapter { const parsed = await response.json() as WireError if (parsed.error?.message) message = parsed.error.message } catch { - // Only swallow error-body parsing: status and code are already captured, - // so malformed gateway JSON must not mask the actionable HTTP failure. + // Only swallow error-body parsing: the stable code and status-line message + // are already captured, so malformed gateway JSON must not mask the failure. } - throw new LlmError(message, code, response.status) + throw new LlmError(message, code) } if (!response.body) { throw new LlmError('DeepSeek API returned no response body', 'EMPTY_RESPONSE') diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 695831b1bd..f9f223b6ff 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -11,12 +11,9 @@ import type {} from '@deepseek-ai/dsh-llm' import { DeepSeekAdapter } from './adapter.ts' import type { DeepSeekCatalogModel } from './adapter.ts' -export { DeepSeekAdapter, httpErrorCode } from './adapter.ts' +export { DeepSeekAdapter } from './adapter.ts' export type { DeepSeekAdapterOptions, DeepSeekCatalogModel } from './adapter.ts' -export { serializeMessages, serializeRequest } from './serialize.ts' export type { RequestDefaults } from './serialize.ts' -export { DONE, parseSse } from './sse.ts' -export { mapFinishReason, mapUsage, translate } from './translate.ts' export type * from './types.ts' export const name = 'llm-deepseek' diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index c0a2f8f482..77a8f1d8a4 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -4,7 +4,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { LlmError, userAgent } from '@deepseek-ai/dsh-llm' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' -import { DeepSeekAdapter, httpErrorCode } from '@deepseek-ai/dsh-llm-deepseek' +import { DeepSeekAdapter } from '@deepseek-ai/dsh-llm-deepseek' +import { httpErrorCode } from '../src/adapter.ts' import { assemble } from './assemble.ts' /** One scripted behavior for the next request the mock server receives. */ @@ -159,7 +160,7 @@ describe('DeepSeekAdapter against a mock server', () => { status, body: JSON.stringify({ error: { message: `failed with ${status}`, type: 't', code: 'c' } }), } - const server = await mockServer([behavior, behavior, behavior]) + const server = await mockServer([behavior, behavior]) const ctx = await harness(server.url) await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })) .rejects.toThrow(`failed with ${status}`) @@ -167,11 +168,6 @@ describe('DeepSeekAdapter against a mock server', () => { assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) .catch((error: unknown) => (error as LlmError).code), ).resolves.toBe(code) - // The numeric HTTP status is carried on the error for explicit handling. - await expect( - assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) - .catch((error: unknown) => (error as LlmError).status), - ).resolves.toBe(status) }) it('keeps the status-line message for JSON error bodies without a message', async () => { @@ -241,6 +237,19 @@ describe('DeepSeekAdapter against a mock server', () => { }) describe('plugin registration and config', () => { + it('keeps wire helpers off the package root', () => { + for (const helper of [ + 'httpErrorCode', + 'serializeMessages', + 'serializeRequest', + 'DONE', + 'parseSse', + 'mapFinishReason', + 'mapUsage', + 'translate', + ]) expect(LlmDeepSeek).not.toHaveProperty(helper) + }) + it('registers the deepseek provider and unregisters on dispose (HMR safety)', async () => { const server = await mockServer([]) const ctx = new Context() diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 0a5d10e23b..8d3b190ed1 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { CallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' -import { serializeMessages, serializeRequest } from '@deepseek-ai/dsh-llm-deepseek' +import { serializeMessages, serializeRequest } from '../src/serialize.ts' function request(overrides: Partial = {}): GenerateOptions { return { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [], ...overrides } diff --git a/packages/llm/llm-deepseek/tests/sse.spec.ts b/packages/llm/llm-deepseek/tests/sse.spec.ts index 2fc297bbec..b18862e4f3 100644 --- a/packages/llm/llm-deepseek/tests/sse.spec.ts +++ b/packages/llm/llm-deepseek/tests/sse.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { LlmError } from '@deepseek-ai/dsh-llm' -import { DONE, parseSse } from '@deepseek-ai/dsh-llm-deepseek' +import { DONE, parseSse } from '../src/sse.ts' /** Build a byte stream from string fragments (fragments = network reads). */ async function* bytes(...fragments: (string | Uint8Array)[]): AsyncGenerator { diff --git a/packages/llm/llm-deepseek/tests/translate.spec.ts b/packages/llm/llm-deepseek/tests/translate.spec.ts index d6968faed5..e62cebc4af 100644 --- a/packages/llm/llm-deepseek/tests/translate.spec.ts +++ b/packages/llm/llm-deepseek/tests/translate.spec.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import { BlockAssembler, LlmError } from '@deepseek-ai/dsh-llm' import type { StreamChunk } from '@deepseek-ai/dsh-llm' -import { DONE, mapFinishReason, mapUsage, translate } from '@deepseek-ai/dsh-llm-deepseek' +import { DONE } from '../src/sse.ts' +import { mapFinishReason, mapUsage, translate } from '../src/translate.ts' async function* feed(...payloads: (string | object)[]): AsyncGenerator { for (const payload of payloads) { diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index bb63d82010..cb2abc8205 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -2,6 +2,8 @@ Generic multi-provider adapter for the harness LLM seam backed by [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai). One plugin instance owns an explicit list of provider profiles; every request selects a profile with `GenerateOptions.provider` and resolves `GenerateOptions.model` dynamically from pi-ai's installed catalog. +The package root exposes the Cordis plugin contract and `PiAiAdapter`; profile resolution, model construction, replay conversion, and stream conversion remain package-internal. + ## Config Configure credentials and deployment-specific transport settings per provider. Omitting `apiKey` delegates authentication to pi-ai's provider-native ambient discovery. `baseURL` overrides only the endpoint of the selected catalog model, preserving its API family and compatibility metadata, so private proxies such as `https://proxy.example.com:8443` remain supported. @@ -76,4 +78,4 @@ Unit tests use pi-ai catalog models redirected to local mock servers and cover p - **Catalog membership is required** — custom model ids that are absent from the installed pi-ai catalog fail with `UNKNOWN_MODEL`, even when a provider profile supplies a custom endpoint. - **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field. - **In-history `system` messages use pi-ai's common context conversion** — provider-specific placement follows pi-ai rather than a harness-owned wire override. -- **`LlmError.status` is unavailable for in-stream failures** — pi-ai error events do not expose a stable HTTP status across providers. +- **Provider HTTP status is unavailable** — pi-ai error events do not expose a stable HTTP status across providers; failures expose only stable harness error codes. diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index cbd0dcd435..ab08f21b81 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -27,12 +27,8 @@ import { Config, resolveProfiles } from './config.ts' export { PiAiAdapter } from './adapter.ts' export type { PiAiAdapterOptions } from './adapter.ts' -export { Config, resolveProfiles } from './config.ts' +export { Config } from './config.ts' export type { PiAiProviderProfile } from './config.ts' -export { toPiContext } from './context.ts' -export { toPiReplayState } from './replay.ts' -export type { PiAiReplayState } from './replay.ts' -export { mapStopReason, mapUsage, toStreamChunks } from './stream.ts' export const name = 'llm-pi-ai' export const inject = ['llm'] diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 1767447d7d..559c36bf27 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -4,7 +4,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { LlmError, userAgent } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' -import { PiAiAdapter, resolveProfiles } from '@deepseek-ai/dsh-llm-pi-ai' +import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai' +import { resolveProfiles } from '../src/config.ts' import { assemble } from './assemble.ts' interface MockServer { @@ -180,6 +181,18 @@ describe('PiAiAdapter provider routing', () => { }) describe('provider profile lifecycle', () => { + it('keeps adapter helpers off the package root', () => { + for (const helper of [ + 'resolveProfiles', + 'toPiContext', + 'toPiReplayState', + 'toPiAssistant', + 'mapStopReason', + 'mapUsage', + 'toStreamChunks', + ]) expect(LlmPiAi).not.toHaveProperty(helper) + }) + it('registers every profile atomically and unregisters on dispose', async () => { const ctx = new Context() await ctx.plugin(LlmService) diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index 50955f0607..2b405c7c91 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest' import { CallId, LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import type { AssistantMessage, AssistantMessageEvent, Usage } from '@earendil-works/pi-ai' -import { mapStopReason, mapUsage, toPiContext, toPiReplayState, toStreamChunks } from '@deepseek-ai/dsh-llm-pi-ai' +import { toPiContext } from '../src/context.ts' +import { toPiReplayState } from '../src/replay.ts' +import { mapStopReason, mapUsage, toStreamChunks } from '../src/stream.ts' function usage(input = 0, output = 0, cacheRead = 0, cacheWrite = 0): Usage { return { diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index a67859ed19..039844040e 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -45,7 +45,7 @@ Every product adapter sends application identity on provider HTTP requests. `att - `LlmAdapter` — abstract base class for provider adapters. The only required method is `stream()`. - `BlockAssembler` — incrementally assembles raw chunks into complete content blocks and an assistant message. The agent loop feeds it raw chunks (logging them for replay) while reading the assembled blocks/message for history. - `HarnessError` — base class for the harness error taxonomy: a stable `code` string (distinct from the human `message`) plus `cause` chaining. Lives here, in the leaf package every other imports, so a single base is shared without a new dependency edge. Per-package errors (`LlmError`, `ToolArgsError`, `InvariantError`, …) extend it. `isHarnessError(value)` narrows at seams. -- `LlmError` — extends `HarnessError`; `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) plus an optional numeric `status` when the failure came from a non-2xx provider response. +- `LlmError` — extends `HarnessError`; its stable `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) is the programmatic failure contract. ### Real adapters diff --git a/packages/llm/llm/src/assembler.ts b/packages/llm/llm/src/assembler.ts index 8780718c7a..a721721fb6 100644 --- a/packages/llm/llm/src/assembler.ts +++ b/packages/llm/llm/src/assembler.ts @@ -39,12 +39,10 @@ export class BlockAssembler { private _replayState: unknown = undefined /** - * Feed one chunk. Returns the completed block when the chunk closes one - * (an explicit `block-end`), otherwise undefined. + * Feed one chunk into the assembly state. * @param chunk - the next raw chunk, in stream order. - * @returns the authoritative block from the first `block-end` at its index; undefined for every other chunk. */ - push(chunk: StreamChunk): ContentBlock | undefined { + push(chunk: StreamChunk): void { switch (chunk.type) { case 'block-start': { if (!this.partials.has(chunk.index)) { @@ -78,7 +76,7 @@ export class BlockAssembler { // and the final assembled block in agreement. if (partial.block) return partial.block = chunk.block - return chunk.block + return } case 'usage': { this._usage = chunk.usage diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 26e9e3b289..020012a1da 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -43,12 +43,10 @@ declare module 'cordis' { /** * Typed error for LLM-related failures. Extends {@link HarnessError}, so the - * `code` string (e.g. `AUTH`, `RATE_LIMIT`, `NO_ADAPTER`) is shared taxonomy; - * `status` carries the HTTP status when the error originated from a non-2xx - * provider response (absent for protocol/usage errors that have no HTTP status). + * `code` string (e.g. `AUTH`, `RATE_LIMIT`, `NO_ADAPTER`) is shared taxonomy. */ export class LlmError extends HarnessError { - constructor(message: string, code: string, public status?: number, options?: ErrorOptions) { + constructor(message: string, code: string, options?: ErrorOptions) { super(message, code, options) this.name = 'LlmError' } diff --git a/packages/llm/llm/tests/assembler.spec.ts b/packages/llm/llm/tests/assembler.spec.ts index 5612e93cb4..f7a5028d14 100644 --- a/packages/llm/llm/tests/assembler.spec.ts +++ b/packages/llm/llm/tests/assembler.spec.ts @@ -29,12 +29,12 @@ describe('BlockAssembler', () => { expect(assembler.message().role).toBe('assistant') }) - it('returns the completed block from push() on block-end', () => { + it('records the completed block from block-end', () => { const assembler = new BlockAssembler() - expect(assembler.push({ type: 'block-start', index: 0, blockType: 'text' })).toBeUndefined() - expect(assembler.push({ type: 'text-delta', index: 0, text: 'hi' })).toBeUndefined() - const block = assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'hi' } }) - expect(block).toEqual({ type: 'text', text: 'hi' }) + assembler.push({ type: 'block-start', index: 0, blockType: 'text' }) + assembler.push({ type: 'text-delta', index: 0, text: 'hi' }) + assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'hi' } }) + expect(assembler.blocks()).toEqual([{ type: 'text', text: 'hi' }]) }) it('tolerates deltas without explicit block-start/end', () => { @@ -57,8 +57,8 @@ describe('BlockAssembler', () => { // push a delta first to guarantee the partial exists assembler.push({ type: 'text-delta', index: 0, text: 'hi' }) // block-end's ensure() must find the existing partial (the second branch path) - const block = assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'hi' } }) - expect(block).toEqual({ type: 'text', text: 'hi' }) + assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'hi' } }) + expect(assembler.blocks()).toEqual([{ type: 'text', text: 'hi' }]) }) it('throws from assemble() when a partial has an unhandled blockType', () => { @@ -128,7 +128,7 @@ describe('assertNever', () => { it('BlockAssembler.push rejects chunks outside the closed StreamChunk union', () => { const assembler = new BlockAssembler() - expect(() => assembler.push({ type: 'rogue-chunk' } as unknown as StreamChunk)) + expect(() => { assembler.push({ type: 'rogue-chunk' } as unknown as StreamChunk) }) .toThrow('unreachable variant in BlockAssembler.push') }) }) @@ -140,26 +140,8 @@ describe('BlockAssembler duplicate-close contract', () => { { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'first' } }, { type: 'block-end', index: 0, block: { type: 'text', text: 'second' } }, ] - const streaming = new BlockAssembler() - const closed = [] - for (const chunk of chunks) { - const block = streaming.push(chunk) - if (block) closed.push(block) - } - - const oneShot = new BlockAssembler() - for (const chunk of chunks) oneShot.push(chunk) - - expect(closed).toEqual([{ type: 'reasoning', text: 'first' }]) - expect(oneShot.blocks()).toEqual([{ type: 'reasoning', text: 'first' }]) - expect(closed).toEqual(oneShot.blocks()) - }) - - it('push returns undefined for a duplicate block-end (it closed nothing)', () => { - const a = new BlockAssembler() - expect(a.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'x' } })) - .toEqual({ type: 'text', text: 'x' }) - expect(a.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'y' } })) - .toBeUndefined() + const assembler = new BlockAssembler() + for (const chunk of chunks) assembler.push(chunk) + expect(assembler.blocks()).toEqual([{ type: 'reasoning', text: 'first' }]) }) }) diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 83429ba0cd..c9a4d2936b 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -255,11 +255,12 @@ describe('LlmService', () => { it('LlmError extends the shared HarnessError base', async () => { const { HarnessError, isHarnessError } = await import('@deepseek-ai/dsh-llm') - const err = new LlmError('boom', 'AUTH', 401) + const cause = new Error('root cause') + const err = new LlmError('boom', 'AUTH', { cause }) expect(err).toBeInstanceOf(HarnessError) expect(isHarnessError(err)).toBe(true) expect(err.code).toBe('AUTH') - expect(err.status).toBe(401) + expect(err.cause).toBe(cause) }) it('HarnessError carries a code, names itself by subclass, and chains cause', async () => { diff --git a/packages/sdk/create-sdk/tests/create.spec.ts b/packages/sdk/create-sdk/tests/create.spec.ts index 9d75700bb9..04a251351d 100644 --- a/packages/sdk/create-sdk/tests/create.spec.ts +++ b/packages/sdk/create-sdk/tests/create.spec.ts @@ -257,6 +257,7 @@ describe('CreateWizard and scaffolder', () => { expect(index).toContain('SdkBootContext') expect(index).toContain('ctx.agents.create') expect(index).toContain('agentOptions: { model: "deepseek-v4-flash" }') + expect(index).not.toContain('AgentId') const tsconfig = parseGeneratedTsConfig(await readFile(join(target, 'tsconfig.base.json'), 'utf8')) const manifest = parseGeneratedPackageManifest(await readFile(join(target, 'package.json'), 'utf8')) expect(tsconfig.compilerOptions.types).toEqual(['node']) diff --git a/packages/sdk/helper/src/features/builtin/app.ts b/packages/sdk/helper/src/features/builtin/app.ts index a89f2c84ae..e4ff11af20 100644 --- a/packages/sdk/helper/src/features/builtin/app.ts +++ b/packages/sdk/helper/src/features/builtin/app.ts @@ -4,6 +4,7 @@ * @module @deepseek-ai/dsh-helper/features/builtin/app */ +import { JsExpression } from '../../documents/cordis-yaml-file.ts' import { featureId } from '../../ids.ts' import type { ProjectProfile } from '../../project/types.ts' import { @@ -94,11 +95,11 @@ class AppOption extends FeatureOption { name: '@deepseek-ai/dsh-stdio', config: { welcome: 'agent REPL ready. Give it a coding task.', - agent: 'main', + sessionId: new JsExpression('process.env.DSH_SDK_SESSION_ID'), }, - }, ['welcome', 'agent'], config => [ + }, ['welcome', 'sessionId'], config => [ ...optionalString(config, 'welcome'), - ...requiredString(config, 'agent'), + ...config.sessionId instanceof JsExpression ? [] : requiredString(config, 'sessionId'), ]), ]) case 'embed': diff --git a/packages/sdk/helper/src/project/npm-dependency-policy.ts b/packages/sdk/helper/src/project/npm-dependency-policy.ts index a8877826e2..727bd6648a 100644 --- a/packages/sdk/helper/src/project/npm-dependency-policy.ts +++ b/packages/sdk/helper/src/project/npm-dependency-policy.ts @@ -23,7 +23,7 @@ const EXTERNAL_NPM_DEPENDENCY_SPECS: Readonly> = { '@cordisjs/plugin-timer': '^1.1.2', '@types/node': '^22.20.0', cordis: '^4.0.0-rc.7', - tsdown: '^0.22.2', + tsdown: '0.22.2', tsx: '^4.22.4', typescript: '^6.0.3', } diff --git a/packages/sdk/helper/src/templates/assets/index.ts.tpl b/packages/sdk/helper/src/templates/assets/index.ts.tpl index d0315db80c..a79818908c 100644 --- a/packages/sdk/helper/src/templates/assets/index.ts.tpl +++ b/packages/sdk/helper/src/templates/assets/index.ts.tpl @@ -2,14 +2,12 @@ import { startSDK, type SdkBootContext } from '@deepseek-ai/dsh-scripts' {{else}} import { randomUUID } from 'node:crypto' -import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import { startSDK, type SdkBootContext } from '@deepseek-ai/dsh-scripts' {{/if}} /** Boot this project's cordis.yml when invoked by dsh-scripts. */ export async function main(boot: SdkBootContext) { - const ctx = await startSDK(new URL('./cordis.yml', import.meta.url)) {{#if isStdio}} const model = boot.args.model if (typeof model !== 'string' || model.length === 0) throw new Error('stdio startup requires --model=') @@ -17,24 +15,35 @@ export async function main(boot: SdkBootContext) { if (resume !== undefined && (typeof resume !== 'string' || resume.length === 0)) { throw new Error('stdio startup requires --resume=') } - if (resume === undefined) { - await ctx.agents.create({ - agentId: AgentId('main'), - sessionId: SessionId(`main-session-${randomUUID()}`), - meta: { cwd: boot.cwd }, - agentOptions: { model }, - }) - } else { - await ctx.agents.resume({ - agentId: AgentId('main'), - resumeSessionId: SessionId(resume), - agentOptions: { model }, - }) + const sessionId = SessionId(resume ?? `main-session-${randomUUID()}`) + process.env.DSH_SDK_SESSION_ID = sessionId +{{/if}} + const ctx = await startSDK(new URL('./cordis.yml', import.meta.url)) +{{#if isStdio}} + try { + if (resume === undefined) { + await ctx.agents.create({ + sessionId, + meta: { cwd: boot.cwd }, + agentOptions: { model }, + }) + } else { + await ctx.agents.resume({ + resumeSessionId: sessionId, + agentOptions: { model }, + }) + } + } catch (error) { + try { + await ctx.fiber.dispose() + } catch (disposeError) { + throw new AggregateError([error, disposeError], 'stdio startup and cleanup failed') + } + throw error } {{else}} {{#if isEmbed}} await ctx.agents.create({ - agentId: AgentId('main'), sessionId: SessionId(`main-session-${randomUUID()}`), meta: { cwd: boot.cwd }, agentOptions: { model: {{modelLiteral}} }, diff --git a/packages/sdk/helper/tests/documents.spec.ts b/packages/sdk/helper/tests/documents.spec.ts index 1ba8b9bd68..1b8050b90f 100644 --- a/packages/sdk/helper/tests/documents.spec.ts +++ b/packages/sdk/helper/tests/documents.spec.ts @@ -282,6 +282,7 @@ describe('package manager strategies', () => { section: 'devDependencies', spec: '^4.0.0-rc.7', }) expect(resolveNpmDependency('@cordisjs/plugin-hmr', 'dependencies', '0.0.1').spec).toBe('^1.0.15') + expect(resolveNpmDependency('tsdown', 'devDependencies', '0.0.1').spec).toBe('0.22.2') expect(resolveNpmDependency('@deepseek-ai/dsh-tools', 'dependencies', '1.2.3').spec).toBe('^1.2.3') expect(() => resolveNpmDependency('unknown', 'dependencies', '0.0.1')).toThrow('no generated-project') }) diff --git a/packages/sdk/helper/tests/project.spec.ts b/packages/sdk/helper/tests/project.spec.ts index d55bb16be9..655b308911 100644 --- a/packages/sdk/helper/tests/project.spec.ts +++ b/packages/sdk/helper/tests/project.spec.ts @@ -167,6 +167,12 @@ describe('SdkProject and ProjectEditSession', () => { expect(index).toContain('SdkBootContext') expect(index).toContain('agents.create') expect(index).toContain('boot.args.resume') + expect(index).not.toContain('AgentId') + expect(index).toContain('const sessionId = SessionId(resume ?? `main-session-${randomUUID()}`)') + expect(index).toContain('process.env.DSH_SDK_SESSION_ID = sessionId') + expect(index).toContain('resumeSessionId: sessionId') + expect(index).toContain('await ctx.fiber.dispose()') + expect(index).toContain("new AggregateError([error, disposeError], 'stdio startup and cleanup failed')") expect(project.packageManifest().scripts).toEqual({ dev: 'dsh-sdk dev index.ts -- --model="deepseek-v4-flash"', build: 'dsh-sdk build', @@ -175,7 +181,11 @@ describe('SdkProject and ProjectEditSession', () => { config: 'dsh-sdk config', }) expect(await readFile(join(project.root, '.env.example'), 'utf8')).toContain('EXA_API_KEY=') - expect(project.cordis.entry('stdio')?.config).toMatchObject({ agent: 'main' }) + expect(project.cordis.entry('stdio')?.config?.sessionId).toMatchObject({ + source: 'process.env.DSH_SDK_SESSION_ID', + }) + expect(await readFile(join(project.root, 'cordis.yml'), 'utf8')) + .toContain('sessionId: !!js process.env.DSH_SDK_SESSION_ID') expect(project.cordis.entry('stdio')?.config).not.toHaveProperty('model') expect(project.cordis.entry('agent-loop')?.config).toEqual({ agents: [] }) expect(project.cordis.entry('system-prompt')?.config?.persona).toContain('{{cwd}}') @@ -286,7 +296,10 @@ describe('SdkProject and ProjectEditSession', () => { const embed = (await embedEdit.commit()).project expect(embed.profile.runInterface).toBe('embed') expect(await readFile(join(embed.root, 'README.md'), 'utf8')).toContain('Embed the harness') - expect(await readFile(join(embed.root, 'index.ts'), 'utf8')).toContain('agents.create') + const embedIndex = await readFile(join(embed.root, 'index.ts'), 'utf8') + expect(embedIndex).toContain('agents.create') + expect(embedIndex).toContain("import { SessionId } from '@deepseek-ai/dsh-session'") + expect(embedIndex).not.toContain('AgentId') await writeFile(join(embed.root, 'README.md'), '# Custom README\n') const modified = await SdkProject.open(embed.root) @@ -862,6 +875,12 @@ describe('extension points', () => { resource.kind === 'cordis-config-entry' && resource.entry.id === 'acp') expect(acpEntry?.entry.id).toBe('acp') expect(acpEntry?.validateConfig?.({ model: '' })).toHaveLength(1) + const stdioEntry = builtins.get(featureId('app')).contribution(selection('app', ['stdio']), profile).resources + .find((resource): resource is CordisConfigEntryResource => + resource.kind === 'cordis-config-entry' && resource.entry.id === 'stdio') + expect(stdioEntry?.validateConfig?.({ welcome: 'ready', sessionId: 1 })).toEqual([ + 'sessionId must be a non-empty string', + ]) const embedOption = app.options.find(option => option.id === 'embed') expect(embedOption?.markerConfigEntries(profile)).toEqual([]) expect(embedOption?.contribution(profile, {}).resources.map(resource => resource.kind)).toEqual([ diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index 69a5f14181..a620051506 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -6,6 +6,8 @@ The ACP provider runs each subagent in a fresh subprocess and drives it as an Ag `start(request)` performs `spawn` → ACP `initialize` → `newSession` before it fulfills. Fulfillment therefore means a remote session is ready and ownership has transferred to the caller. A spawn, initialization, new-session, or pre-publication cancellation failure rejects only after the subprocess has been reaped. +The returned run id is minted in the parent namespace. The child server's session id remains private to ACP wire calls because ACP guarantees it only within that fresh child process; using it as the parent lifecycle id could collide with another remote run or a local agent. + After publication, the provider sends the prompt and collects streamed `agent_message_chunk` text into `SubagentResult.output`. A prompt/transport failure resolves with `stopReason: 'error'`, or `aborted` when the required request signal or disposal requested cancellation. `dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, closes stdin, waits `disposeEofGraceMs`, escalates to SIGTERM, waits `disposeGraceMs`, and finally uses SIGKILL if necessary. Every run uses a fresh process; process pooling is not implemented. diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index ec55845f91..fa16edcf60 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -24,6 +24,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-subagent-subprocess": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -36,6 +37,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-subprocess": "workspace:^", "@cordisjs/plugin-loader": "^1.0.0-rc.5", diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 9416d58865..a10a87a940 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -23,8 +23,8 @@ import { type SessionNotification, type StopReason, } from '@agentclientprotocol/sdk' -import { AgentId } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' import { buildChildEnv, disposeChildProcess, spawnFailure } from '@deepseek-ai/dsh-subagent-subprocess' @@ -149,9 +149,11 @@ function toError(value: unknown): Error { * @returns the ready run handle for the child subprocess. */ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Promise { - const id = AgentId(randomUUID()) - if (request.signal.aborted) throw new Error('subagent request was aborted before the ACP child started') + // ACP session ids are unique only within the child server. The lifecycle id + // is minted in the parent namespace so fresh processes cannot collide with + // each other or with a local agent that happens to use the same session id. + const id = SessionId(randomUUID()) // Keep diagnostics on parent stderr; only ACP output contributes to the result. const child = spawn(spec.command, spec.args, { @@ -241,7 +243,9 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe clientCapabilities: {}, }) const session = await conn.newSession({ cwd: spec.cwd, mcpServers: [] }) - sessionId = session.sessionId + const returnedSessionId: unknown = Reflect.get(session, 'sessionId') + if (typeof returnedSessionId !== 'string') throw new Error('ACP child published without a session id') + sessionId = returnedSessionId if (flags.cancelled) throw new Error('subagent cancelled before the ACP session started') })(), spawnFailed.then((err): never => { throw err }), @@ -253,13 +257,18 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe if (flags.cancelled) throw new Error('subagent request was aborted before the ACP child started') throw toError(error) } + // The startup transaction validates the returned id before it can fulfill. + // This assertion carries that cross-closure invariant into TypeScript. + /* v8 ignore next */ + if (sessionId === undefined) throw new Error('unreachable: ACP startup fulfilled without a session id') + const remoteSessionId = sessionId const result: Promise = (async (): Promise => { try { // Race the remote turn against local cancellation. const prompt = async (): Promise => { // The startup phase cannot fulfill without assigning the session id. - const promptResult = await conn.prompt({ sessionId: sessionId as string, prompt: toAcpPrompt(request.prompt) }) + const promptResult = await conn.prompt({ sessionId: remoteSessionId, prompt: toAcpPrompt(request.prompt) }) return { output: collectOutput(), stopReason: acpStopReason(promptResult.stopReason) } } return await Promise.race([ @@ -285,6 +294,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe let disposal: Promise | undefined return { id, + localAgent: undefined, result, dispose(): Promise { if (disposal !== undefined) return disposal diff --git a/packages/subagent/subagent-acp/tests/mock-acp-server.ts b/packages/subagent/subagent-acp/tests/mock-acp-server.ts index 1496f882aa..2bbf457c18 100644 --- a/packages/subagent/subagent-acp/tests/mock-acp-server.ts +++ b/packages/subagent/subagent-acp/tests/mock-acp-server.ts @@ -1,9 +1,45 @@ /** - * Minimal no-network ACP child process for keyless backend tests. Environment variables script its - * text and stop reason, a cancel-cooperative or cancel-ignoring hang, permission requests, and a - * readiness marker. Disposal fixtures can delay an EOF flush, ignore EOF but exit and mark - * SIGTERM, or trap SIGTERM to require SIGKILL. The specs run this protocol-only fixture directly - * with Node's type stripping; it imports no harness code or workspace paths. + * A minimal mock ACP AGENT, run as a subprocess, for the keyless + * `dsh-subagent-acp` tests. It speaks the agent side of ACP over stdio and is + * fully scripted by environment variables — no model, no network: + * + * - `MOCK_TEXT` — the assistant text it streams as one `agent_message_chunk`. + * - `MOCK_STOP` — the ACP `StopReason` it returns from `prompt` + * (`end_turn` default, or `max_tokens`/`refusal`/…). + * - `MOCK_HANG` — if `1`, `prompt` never resolves on its own (it waits for + * a `session/cancel`), to exercise the client's cancel path. + * - `MOCK_IGNORE_CANCEL` — if `1` (with MOCK_HANG), the agent receives + * `session/cancel` but NEVER resolves the pending prompt + * and never exits — a non-cooperative child. The backend's + * `result` must still settle `aborted` on its own and + * `dispose()` must still kill the process. + * - `MOCK_PERMISSION` — if `1`, the agent calls `session/request_permission` + * before answering, to exercise the client's auto-answer. + * - `MOCK_READY_FILE` — if set, the path the agent touches once its `prompt` + * handler is in flight (it has streamed its chunk). A test + * polls for this file to cancel on a CONDITION rather than + * an arbitrary timeout (subprocess cold-start is variable). + * - `MOCK_MISSING_SESSION_ID` — if `1`, return a malformed empty `session/new` + * response to exercise startup rollback. + * - `MOCK_FLUSH_ON_EOF` — if set, on stdin EOF the agent takes an async beat + * (MOCK_FLUSH_DELAY_MS, default 150) simulating the real + * acp-agent's EOF-driven quiesce+flush, then touches this + * path and exits ON ITS OWN — no signal. Stands in for a + * child whose durable flush completes only if dispose + * gives EOF a real window before escalating to SIGTERM. + * - `MOCK_IGNORE_EOF` — if `1`, keep the event loop alive past stdin EOF (a bare + * timer) but install a SIGTERM handler that exits (and, if + * MOCK_SIGTERM_FILE is set, touches it as an observable + * proof the SIGTERM rung fired). The child ignores the + * graceful EOF window yet dies cooperatively on SIGTERM — + * exercising dispose's middle tier (exit during the SIGTERM + * grace, before the SIGKILL escalation). Touches + * MOCK_READY_FILE once armed. + * + * It is not a test spec: the specs launch this protocol-only fixture through + * the mode-aware example resolver (tsx in source mode, Node type stripping in + * built mode). It imports no harness code or workspace paths. + * * @module @deepseek-ai/dsh-subagent-acp/tests/mock-acp-server */ @@ -64,7 +100,8 @@ function makeAgent(conn: AgentSideConnection): Agent { writeFileSync(NEWSESSION_GATE.ready, 'at-newSession') while (!existsSync(NEWSESSION_GATE.go)) await new Promise(r => setTimeout(r, 10)) } - return { sessionId: randomUUID() } + if (process.env.MOCK_MISSING_SESSION_ID === '1') return {} as NewSessionResponse + return { sessionId: process.env.MOCK_SESSION_ID ?? randomUUID() } }, authenticate(_params: AuthenticateRequest): Promise { // No auth methods advertised; nothing to do. @@ -124,8 +161,11 @@ function makeAgent(conn: AgentSideConnection): Agent { process.exit(1) } if (IGNORE_CANCEL) { - // A non-cooperative child receives cancellation but neither resolves nor exits. The - // backend must still settle `aborted`, and disposal must kill the process. + // A NON-COOPERATIVE child: receive session/cancel but never resolve the + // pending prompt and never exit. The backend's `result` must still settle + // `aborted` on its own (the cancel-settle race), and `dispose()` must + // still kill the process — proving cancellation does not depend on the + // child cooperating. return Promise.resolve() } resolveCancel?.('cancelled') @@ -142,9 +182,12 @@ new AgentSideConnection( ), ) -// Under MOCK_TRAP_SIGTERM, ignore SIGTERM and keep stdin open so the process neither quiesces -// on EOF nor dies on the graceful signal — exercising the backend dispose path's SIGKILL -// escalation. READY_FILE proves the trap was armed before the test disposes the run. +// Under MOCK_TRAP_SIGTERM, ignore SIGTERM and keep stdin open so the process +// neither quiesces on EOF nor dies on the graceful signal — exercising the +// backend dispose path's SIGKILL escalation. Without this the process exits +// normally on SIGTERM / stdin end. Touch READY_FILE once the trap is armed, so +// a test waits for that CONDITION before disposing (the trap must be in place, +// not merely the process spawned — otherwise SIGTERM hits the default handler). if (process.env.MOCK_TRAP_SIGTERM === '1') { process.on('SIGTERM', () => { /* trapped: refuse to exit on the graceful signal */ }) // Keep the event loop alive (a bare timer) so nothing else lets it exit. @@ -152,10 +195,13 @@ if (process.env.MOCK_TRAP_SIGTERM === '1') { if (READY_FILE !== undefined) writeFileSync(READY_FILE, 'trap-armed') } -// Under MOCK_FLUSH_ON_EOF, model the real acp-agent's EOF-driven quiesce: on stdin 'end' (the -// dispose path's `child.stdin.end()`), take an ASYNC beat to "flush", then touch the marker and -// exit on its own. A signal sent before MOCK_FLUSH_DELAY_MS would suppress the marker, so it proves -// the EOF grace window was long enough for durable flush. +// Under MOCK_FLUSH_ON_EOF, model the real acp-agent's EOF-driven quiesce: on +// stdin 'end' (the dispose path's `child.stdin.end()`), take an ASYNC beat to +// "flush", then touch the marker and exit ON OUR OWN — no signal involved. The +// beat is MOCK_FLUSH_DELAY_MS (default 150). A dispose that sends SIGTERM before +// the beat completes (no graceful window, or an EOF grace shorter than the +// flush) default-terminates this process and the marker is missing; a dispose +// that gives the EOF quiesce enough window first lets the flush land. if (FLUSH_ON_EOF !== undefined) { const flushDelayMs = Number(process.env.MOCK_FLUSH_DELAY_MS ?? '150') process.stdin.on('end', () => { @@ -166,9 +212,14 @@ if (FLUSH_ON_EOF !== undefined) { }) } -// Ignore EOF but exit on SIGTERM to exercise the middle disposal tier before SIGKILL. The signal -// marker distinguishes that catchable rung from an immediate, uncatchable SIGKILL; READY_FILE -// proves the handler was armed before disposal. +// Under MOCK_IGNORE_EOF, keep the loop alive past stdin EOF (so the graceful EOF +// window times out) but INSTALL A SIGTERM HANDLER that records it and exits — the +// child ignores the graceful EOF window yet dies cooperatively on SIGTERM, +// exercising dispose's MIDDLE tier (exit during the SIGTERM grace, before the +// SIGKILL escalation). When MOCK_SIGTERM_FILE is set the handler touches it, an +// OBSERVABLE proof that the SIGTERM rung fired: if dispose skipped the middle +// rung and jumped EOF→SIGKILL, SIGKILL is uncatchable so the handler never runs +// and the marker is missing. Touch READY_FILE once armed (a test waits on it). if (process.env.MOCK_IGNORE_EOF === '1') { const sigtermFile = process.env.MOCK_SIGTERM_FILE process.on('SIGTERM', () => { diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index bfc8476bae..e4231c1598 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import SubagentService from '@deepseek-ai/dsh-subagent' -import { buildChildEnv, SENSITIVE_ENV_PATTERN } from '@deepseek-ai/dsh-subagent-subprocess' +import { buildChildEnv } from '@deepseek-ai/dsh-subagent-subprocess' import type { Agent } from '@deepseek-ai/dsh-agent' import * as acp from '../src/index.ts' import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts' @@ -58,7 +58,7 @@ function text(blocks: { type: string; text?: string }[]): string { /** * Poll until `file` exists (the mock touches it once its prompt is in flight), * so a cancel test waits on a CONDITION rather than an arbitrary timeout — the - * subprocess cold-start under tsx is variable, and a fixed sleep both flakes and + * subprocess cold-start is variable, and a fixed sleep both flakes and * slows the suite. Fails loud if the child never signals readiness. */ async function waitForFile(file: string, timeoutMs = 5000): Promise { @@ -108,7 +108,6 @@ describe('buildChildEnv', () => { // The explicitly-supplied key survives (an opt-in for the child's creds). expect(env.DEEPSEEK_API_KEY).toBe('explicit') // A normal ambient var is forwarded. - expect(SENSITIVE_ENV_PATTERN.test('PATH')).toBe(false) expect(env.PATH).toBe(process.env.PATH) } finally { delete process.env.DSH_ACP_TEST_SECRET_TOKEN @@ -117,15 +116,22 @@ describe('buildChildEnv', () => { }) describe('dsh-subagent-acp', () => { - it('drives a child process to completion and returns its streamed output', async () => { - const ctx = await setup({ MOCK_TEXT: 'hello from acp child', MOCK_STOP: 'end_turn' }) + it('drives child processes with parent-unique run ids and returns streamed output', async () => { + const ctx = await setup({ MOCK_TEXT: 'hello from acp child', MOCK_STOP: 'end_turn', MOCK_SESSION_ID: 'acp-child-session' }) const run = await ctx.subagents.start('acp', request('do X')) + expect(run.id).not.toBe('acp-child-session') const result = await run.result expect(result.stopReason).toBe('completed') expect(text(result.output)).toBe('hello from acp child') const disposal = run.dispose() expect(run.dispose()).toBe(disposal) await disposal + + const nextRun = await ctx.subagents.start('acp', request('do X again')) + expect(nextRun.id).not.toBe(run.id) + expect(nextRun.id).not.toBe('acp-child-session') + await nextRun.result + await nextRun.dispose() }) it('maps a max_tokens stop reason', async () => { @@ -184,6 +190,31 @@ describe('dsh-subagent-acp', () => { } }) + it('reaps a child whose session/new response omits the session id', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'acp-malformed-session-')) + const flushed = join(tmp, 'flushed') + try { + await expect(startAcpRun(request(), { + command: process.execPath, + args: [mockServer], + cwd: process.cwd(), + permission: 'reject', + env: { + MOCK_MISSING_SESSION_ID: '1', + MOCK_FLUSH_ON_EOF: flushed, + MOCK_FLUSH_DELAY_MS: '20', + }, + disposeEofGraceMs: 1000, + disposeGraceMs: 100, + })).rejects.toThrow('ACP child published without a session id') + // Startup rejects only after its private child reaches quiescence. The + // marker proves rollback closed stdin and allowed the child's EOF flush. + expect(existsSync(flushed)).toBe(true) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + it('dispose escalates SIGTERM → SIGKILL for a child that traps SIGTERM (bounded quiescence)', async () => { // The child traps SIGTERM and keeps its event loop alive, so a graceful // term alone would hang dispose forever. With a short grace, dispose must diff --git a/packages/subagent/subagent-fork/README.md b/packages/subagent/subagent-fork/README.md index 344b42732d..244811ab88 100644 --- a/packages/subagent/subagent-fork/README.md +++ b/packages/subagent/subagent-fork/README.md @@ -6,7 +6,7 @@ The fork provider creates an in-process child seeded with the parent's completed The parent's current tool-calling turn is still open when a subagent starts: its log contains the assistant tool call but not the matching tool result or `turn/end`. Copying that raw log would give the child an invalid, unbalanced session. -Fork therefore uses `completedTurnPrefix(parent.session.events)`: the contiguous prefix ending at the last `turn/end`. The child sees all completed parent turns and none of the in-flight turn. If the parent has not completed a turn yet, the seed is empty and the child behaves like a fresh spawn. +Fork therefore computes the contiguous prefix ending at the last `turn/end`. The child sees all completed parent turns and none of the in-flight turn. If the parent has not completed a turn yet, the seed is empty and the child behaves like a fresh spawn. The seed transfers conversation history only. The child still receives a fresh flat registration scope; it does not inherit the parent's tool restrictions or authority. diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts index fa212bc938..a96ce4f06e 100644 --- a/packages/subagent/subagent-fork/src/index.ts +++ b/packages/subagent/subagent-fork/src/index.ts @@ -39,7 +39,7 @@ export const Config: z = z.object({ * @param parent - the agent whose session log to slice. * @returns the seed events, contiguous from seq 0; empty when no turn has completed. */ -export function completedTurnPrefix(parent: Agent): SessionEvent[] { +function completedTurnPrefix(parent: Agent): SessionEvent[] { const events = parent.session.events const lastEnd = events.findLast(e => e.type === 'turn/end') if (lastEnd === undefined) return [] diff --git a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts index 4a2c6b9133..df3b74d346 100644 --- a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts +++ b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as Invariants from '@deepseek-ai/dsh-invariants' @@ -30,7 +30,7 @@ async function setup(script: Script) { await ctx.plugin(Spawn, { providerName: 'spawn' }) await ctx.plugin(fork, { providerName: 'fork' }) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) - const parent = ctx.agentLoop.create(AgentId('parent'), { provider: 'mock', model: 'mock' }) + const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) return { ctx, parent } } diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index f1ca8f0b75..e228830554 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as Invariants from '@deepseek-ai/dsh-invariants' @@ -10,7 +11,6 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent import type { StreamChunk } from '@deepseek-ai/dsh-llm' import * as fork from '../src/index.ts' import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess' -import { completedTurnPrefix } from '../src/index.ts' type Script = ConstructorParameters[0] @@ -36,7 +36,7 @@ async function setup(script: Script) { await ctx.plugin(SubagentService) await ctx.plugin(fork, { providerName: 'fork' }) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) - const parent = ctx.agentLoop.create(AgentId('parent'), { provider: 'mock', model: 'mock' }) + const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) return { ctx, parent } } @@ -44,28 +44,6 @@ function text(blocks: { type: string; text?: string }[]): string { return blocks.filter(b => b.type === 'text').map(b => b.text).join('') } -describe('completedTurnPrefix', () => { - it('returns an empty prefix for a parent that has never completed a turn', async () => { - const { parent } = await setup([]) - expect(completedTurnPrefix(parent)).toEqual([]) - }) - - it('returns the balanced prefix up to and including the last turn/end', async () => { - const { parent } = await setup([textResponse('first'), textResponse('second')]) - parent.send([{ type: 'text', text: 'q1' }]) - await parent.whenIdle() - parent.send([{ type: 'text', text: 'q2' }]) - await parent.whenIdle() - - const prefix = completedTurnPrefix(parent) - // Ends exactly at the last turn/end; seq is contiguous from 0. - expect(prefix.at(-1)?.type).toBe('turn/end') - expect(prefix.map(e => e.seq)).toEqual(prefix.map((_, i) => i)) - // Both completed turns are present. - expect(prefix.filter(e => e.type === 'turn/end')).toHaveLength(2) - }) -}) - describe('dsh-subagent-fork', () => { it('emits subagent/start only after the seeded child is published', async () => { const { ctx, parent } = await setup([textResponse('child answer')]) @@ -88,7 +66,6 @@ describe('dsh-subagent-fork', () => { // The parent has never completed a turn → empty prefix → the provider omits // the seed → the child runs fresh. Exercises the `seed.length > 0` false arm. const { ctx, parent } = await setup([textResponse('fresh child')]) - expect(completedTurnPrefix(parent)).toEqual([]) const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child q' }], parent }) const result = await run.result expect(result.stopReason).toBe('completed') @@ -96,6 +73,24 @@ describe('dsh-subagent-fork', () => { const child = ctx.agents.get(run.id)! // Only the child's own turn — no seeded parent turns. expect(child.session.events.filter(e => e.type === 'turn/end')).toHaveLength(1) + expect(child.session.header.seedLength).toBeUndefined() + await run.dispose() + }) + + it('seeds every completed parent turn through the last turn/end', async () => { + const { ctx, parent } = await setup([textResponse('first'), textResponse('second'), textResponse('child')]) + parent.send([{ type: 'text', text: 'q1' }]) + await parent.whenIdle() + parent.send([{ type: 'text', text: 'q2' }]) + await parent.whenIdle() + const parentPrefixLen = parent.session.events.length + + const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child q' }], parent }) + await run.result + const child = ctx.agents.get(run.id)! + expect(child.session.header.seedLength).toBe(parentPrefixLen) + expect(child.session.events.slice(0, parentPrefixLen).at(-1)?.type).toBe('turn/end') + expect(child.session.events.slice(0, parentPrefixLen).filter(e => e.type === 'turn/end')).toHaveLength(2) await run.dispose() }) diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index dc10405db7..ca54dab7c0 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -26,7 +26,7 @@ After fulfillment, the caller owns the run. Provider-plugin unload does not revo `InProcessRunOptions` is `{ seed?: SessionEvent[] }`. Spawn omits it. Fork supplies a balanced completed-turn prefix and records its length so the result reader never mistakes a seeded parent message for child output. -`depthOf(agent)` reads `AgentOptions.subagentDepth`, treating absence as top-level depth zero and rejecting malformed stored values. `SubagentDepthError` reports an attempted child depth above `maxDepth`; an unrepresentable depth above the safe-integer domain is a `RangeError`. +Depth enforcement is internal to `startInProcessRun`: it reads `AgentOptions.subagentDepth`, treats absence as top-level depth zero, rejects malformed stored values, and reports an attempted child depth above `maxDepth`. An unrepresentable depth above the safe-integer domain is a `RangeError`. ## Structured output diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 8ec8a6aac2..6e62ee5127 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -9,7 +9,7 @@ import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' -import { AgentId, type Agent, type AgentOptions } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent' @@ -36,7 +36,7 @@ declare module '@deepseek-ai/dsh-agent' { * @param agent - the agent whose options carry the depth. * @returns its non-negative safe-integer depth. */ -export function depthOf(agent: Agent): number { +function depthOf(agent: Agent): number { const depth = agent.options.subagentDepth if (depth === undefined) return 0 if (!Number.isSafeInteger(depth) || depth < 0 || Object.is(depth, -0)) { @@ -46,7 +46,7 @@ export function depthOf(agent: Agent): number { } /** Thrown when starting a child would exceed the requested depth cap. */ -export class SubagentDepthError extends Error { +class SubagentDepthError extends Error { constructor(public readonly attemptedDepth: number, public readonly maxDepth: number) { super(`subagent depth ${attemptedDepth} exceeds maxDepth ${maxDepth}`) this.name = 'SubagentDepthError' @@ -104,7 +104,7 @@ export async function startInProcessRun( throw new SubagentDepthError(childDepth, request.maxDepth) } - const childId = AgentId(randomUUID()) + const childId = SessionId(randomUUID()) const seedLength = options.seed?.length ?? 0 const parentHeader = parent.session.header const parentProvider = parent.options.provider @@ -129,8 +129,7 @@ export async function startInProcessRun( const flags = { cancelled: false } const handle = await parent.ctx.agents.create({ - agentId: childId, - sessionId: SessionId(randomUUID()), + sessionId: childId, meta: { ...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {}, parentSession: parentHeader.id, @@ -176,6 +175,7 @@ export async function startInProcessRun( return { id: childId, + localAgent: child, result, dispose(): Promise { request.signal.removeEventListener('abort', onAbort) diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 4222dde760..e50457bcb4 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { CallId, type ContentBlock, type GenerateOptions } from '@deepseek-ai/dsh-llm' -import { AgentId } from '@deepseek-ai/dsh-agent' import type { ContinuationDecision } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as Invariants from '@deepseek-ai/dsh-invariants' @@ -61,7 +61,7 @@ async function setup(script: Script, options: SetupOptions = {}) { start: (request: SubagentStartRequest) => startInProcessRun(request, {}), }) ctx.llm.registerAdapter(['mock'], adapter) - const parent = ctx.agentLoop.create(AgentId('parent'), { provider: 'mock', model: 'mock' }) + const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) return { ctx, parent, adapter, disposeProvider } } @@ -322,7 +322,7 @@ describe('in-process structured output', () => { await expect(ctx.subagents.start('spawn', structuredRequest(parent, { outputSchema: { type: 'object', oneOf: [] } as unknown as StructuredOutputSchema, }))).rejects.toThrow(/unsupported output schema/) - expect(ctx.agents.get(AgentId('parent'))).toBeDefined() + expect(ctx.agents.get(SessionId('parent'))).toBeDefined() }) it('a schema carrying non-JSON values fails as OutputSchemaError at the validation boundary', async () => { diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index eeab453e40..13029ef3e7 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -1,12 +1,13 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import { type Agent } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService from '@deepseek-ai/dsh-subagent' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' -import { depthOf, SubagentDepthError, startInProcessRun } from '../src/index.ts' +import { startInProcessRun } from '../src/index.ts' type Script = ConstructorParameters[0] @@ -17,7 +18,7 @@ async function setup(script: Script) { await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) - const parent = ctx.agentLoop.create(AgentId('parent'), { provider: 'mock', model: 'mock' }) + const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) return { ctx, parent } } @@ -29,19 +30,6 @@ function text(blocks: readonly { type: string; text?: string }[]): string { return blocks.filter(block => block.type === 'text').map(block => block.text).join('') } -describe('depthOf', () => { - it('reads zero for a top-level agent and an explicit child depth', async () => { - const { parent } = await setup([]) - expect(depthOf(parent)).toBe(0) - expect(depthOf({ options: { subagentDepth: 3 } } as unknown as Agent)).toBe(3) - }) - - it.each([Number.NaN, 1.5, -1, -0, Number.MAX_SAFE_INTEGER + 1])('rejects malformed depth %s', (value) => { - expect(() => depthOf({ options: { subagentDepth: value } } as unknown as Agent)) - .toThrow('non-negative safe integer') - }) -}) - describe('startInProcessRun', () => { it('returns only after publication, drives a fresh child, and disposes it', async () => { const { ctx, parent } = await setup([textResponse('driver answer')]) @@ -50,7 +38,7 @@ describe('startInProcessRun', () => { const result = await run.result expect(result.stopReason).toBe('completed') expect(text(result.output)).toBe('driver answer') - expect(depthOf(ctx.agents.get(run.id)!)).toBe(1) + expect(ctx.agents.get(run.id)!.options.subagentDepth).toBe(1) await run.dispose() await run.dispose() expect(ctx.agents.get(run.id)).toBeUndefined() @@ -75,7 +63,12 @@ describe('startInProcessRun', () => { await expect(startInProcessRun({ ...request(parent), maxDepth: -1 }, {})) .rejects.toThrow('non-negative safe integer') await expect(startInProcessRun({ ...request(parent), maxDepth: 0 }, {})) - .rejects.toBeInstanceOf(SubagentDepthError) + .rejects.toMatchObject({ name: 'SubagentDepthError' }) + for (const value of [Number.NaN, 1.5, -1, -0, Number.MAX_SAFE_INTEGER + 1]) { + const malformed = { options: { subagentDepth: value } } as unknown as Agent + await expect(startInProcessRun(request(malformed), {})) + .rejects.toThrow('agent subagentDepth must be a non-negative safe integer') + } const maxParent = { options: { subagentDepth: Number.MAX_SAFE_INTEGER } } as unknown as Agent await expect(startInProcessRun(request(maxParent), {})).rejects.toBeInstanceOf(RangeError) }) diff --git a/packages/subagent/subagent-spawn/tests/spawn.e2e.ts b/packages/subagent/subagent-spawn/tests/spawn.e2e.ts index 1b278c9748..89efb2b815 100644 --- a/packages/subagent/subagent-spawn/tests/spawn.e2e.ts +++ b/packages/subagent/subagent-spawn/tests/spawn.e2e.ts @@ -3,8 +3,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' import { spawnHarness, waitForIdle } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * With-key smoke for the in-process spawn backend: a REAL parent agent delegates @@ -29,7 +29,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('spawn backend with-key smoke', ( it('a parent delegates to a child that writes a file on disk', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-spawn-e2e-')) ctx = await spawnHarness(workdir) - const parent = ctx.agentLoop.create(AgentId('e2e-parent'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) + const parent = ctx.agentLoop.create(SessionId('e2e-parent'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) parent.send([{ type: 'text', text: 'Use the subagent tool to delegate this exact task: "Use the bash tool to write the text ' diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 147b83dd9a..7de5f6f4d6 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -9,7 +9,7 @@ import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as spawn from '../src/index.ts' -import { depthOf, STRUCTURED_OUTPUT_TOOL, SubagentDepthError } from '@deepseek-ai/dsh-subagent-inprocess' +import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess' type Script = ConstructorParameters[0] @@ -29,7 +29,7 @@ async function setup(script: Script) { await ctx.plugin(SubagentService) await ctx.plugin(spawn, { providerName: 'spawn' }) ctx.llm.registerAdapter(['mock'], adapter) - const parent = ctx.agentLoop.create(AgentId('parent'), { provider: 'mock', model: 'mock' }) + const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) return { ctx, parent, adapter } } @@ -110,11 +110,11 @@ describe('dsh-subagent-spawn', () => { it('stamps child depth = parent depth + 1 (via the merged AgentOptions field)', async () => { const { ctx, parent } = await setup([textResponse('x')]) - expect(depthOf(parent)).toBe(0) + expect(parent.options.subagentDepth).toBeUndefined() const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) await run.result const child = ctx.agents.get(run.id)! - expect(depthOf(child)).toBe(1) + expect(child.options.subagentDepth).toBe(1) await run.dispose() }) @@ -122,7 +122,7 @@ describe('dsh-subagent-spawn', () => { const { ctx, parent } = await setup([]) // parent is depth 0, child would be depth 1 — cap at 0 forbids any child. await expect(start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 })) - .rejects.toThrow(SubagentDepthError) + .rejects.toThrow('subagent depth 1 exceeds maxDepth 0') }) it('maps a child that hit its token ceiling to stopReason "max-tokens"', async () => { @@ -225,7 +225,6 @@ describe('dsh-subagent-spawn', () => { const { ctx } = await setup([textResponse('x')]) // A parent WITH a cwd (config agents have none, so create one explicitly). const parentHandle = await ctx.agents.create({ - agentId: AgentId('cwd-parent'), sessionId: SessionId('cwd-parent-session'), meta: { cwd: '/tmp/parent-workspace' }, agentOptions: { provider: 'mock', model: 'mock' }, @@ -242,7 +241,6 @@ describe('dsh-subagent-spawn', () => { const { ctx } = await setup([textResponse('explicit model child')]) // A parent with NO model (its own turns would need one supplied per-request). const parentHandle = await ctx.agents.create({ - agentId: AgentId('modelless-parent'), sessionId: SessionId('modelless-parent-session'), agentOptions: {}, }) @@ -302,7 +300,7 @@ describe('dsh-subagent-spawn', () => { await ctx.plugin(SubagentService) const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) ctx.llm.registerAdapter(['mock'], adapter) - const parent = ctx.agentLoop.create(AgentId('parent'), { provider: 'mock', model: 'mock' }) + const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) const controller = new AbortController() const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'q' }], @@ -329,7 +327,7 @@ describe('dsh-subagent-spawn', () => { await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) - const parent = ctx.agentLoop.create(AgentId('parent'), { provider: 'mock', model: 'mock' }) + const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) const parentEffects = parent.ctx.fiber.getEffects().length const published: string[] = [] ctx.on('session/created', () => void published.push('session/created')) @@ -422,7 +420,6 @@ describe('dsh-subagent-spawn', () => { const { ctx } = await setup([]) // A handle-owned parent we can dispose (config agents dispose with the loop fiber). const parentHandle = await ctx.agents.create({ - agentId: AgentId('doomed-parent'), sessionId: SessionId('doomed-s'), agentOptions: { provider: 'mock', model: 'mock' }, }) @@ -445,7 +442,6 @@ describe('dsh-subagent-spawn', () => { it('parent disposal during the child setup transaction prevents every publication notification', async () => { const { ctx } = await setup([]) const parentHandle = await ctx.agents.create({ - agentId: AgentId('setup-race-parent'), sessionId: SessionId('setup-race-parent-session'), agentOptions: { provider: 'mock', model: 'mock' }, }) diff --git a/packages/subagent/subagent-subprocess/README.md b/packages/subagent/subagent-subprocess/README.md index a3c0905422..f15defcf2a 100644 --- a/packages/subagent/subagent-subprocess/README.md +++ b/packages/subagent/subagent-subprocess/README.md @@ -6,7 +6,7 @@ Every tunable is a **parameter**: the dispose ladder takes its grace periods per ## What it exports -### `SENSITIVE_ENV_PATTERN` / `buildChildEnv(extra)` +### `buildChildEnv(extra)` The credential env scrub (same pattern as the [bash executor](../../bash/bash-local/README.md)): the child env is the ambient env minus credential-shaped vars (`/KEY|SECRET|TOKEN/i`), with `extra` layered on top AFTER the scrub. `PATH`, `HOME`, `TMPDIR`, locale, and proxy vars survive, so the child CLI runs normally; the parent's own secrets never leak implicitly, while an explicitly supplied credential (the child's OWN key in a backend's `env` config) still reaches the child. @@ -14,10 +14,6 @@ The credential env scrub (same pattern as the [bash executor](../../bash/bash-lo Spawn-failure capture: a promise that resolves (never rejects) with the child's first `error` event. A spawn failure such as `ENOENT` is an event, not a thrown exception — without a listener Node crashes the parent process — so call this in the same tick as `spawn()` and race it in the run's result path; a bad command then settles as an ordinary child-level failure. For a child that spawns cleanly the promise never settles. -### `waitForExit(child)` / `exitsWithin(child, ms)` - -Exit waits over a `ChildProcess`: resolve once the child exits by any code or signal (immediately if it is already gone), or race that against a timer (`true` = exited in time). The race cleans up after itself on both outcomes — the pending timer is `unref()`ed and cleared on exit, the exit listener removed on timeout — so repeated calls (the dispose ladder's tiers, a poll loop) never accumulate listeners on the child. - ### `disposeChildProcess(child, graces)` The three-tier dispose ladder. Resolves only once the child has ACTUALLY exited — quiescence reached, not merely requested (see [defensive patterns](../../../docs/defensive-patterns.md)): @@ -28,6 +24,8 @@ The three-tier dispose ladder. Resolves only once the child has ACTUALLY exited The two graces (`DisposeLadderGraces`) come from the consuming plugin's `disposeEofGraceMs`/`disposeGraceMs` Config fields; the EOF window is deliberately a separate — usually wider — grace than the signal tier, since a cooperative child's EOF teardown may itself await a signal-trapping grandchild plus a final flush. +The exit waits are internal to this ladder. They clean up their timer and listener on either outcome, so escalation never accumulates listeners on the child. + ### `createIsolatedConfigDir(prefix, pinnedPath?)` A per-run isolated config directory for an external CLI child (the target of `CLAUDE_CONFIG_DIR` / `CODEX_HOME`-style redirection), so child behavior is a function of deployment config alone — never of whatever `~/.claude` / `~/.codex`-style state exists on the host. Returns an `IsolatedConfigDir` handle: `path` goes into the child env, `remove()` runs on dispose. diff --git a/packages/subagent/subagent-subprocess/src/index.ts b/packages/subagent/subagent-subprocess/src/index.ts index 21bcca788e..3831d2bb6a 100644 --- a/packages/subagent/subagent-subprocess/src/index.ts +++ b/packages/subagent/subagent-subprocess/src/index.ts @@ -20,13 +20,12 @@ import { join } from 'node:path' * the scrub, so an intended `DEEPSEEK_API_KEY` survives while an incidental * `AWS_SECRET_ACCESS_KEY` does not. */ -export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i +const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i /** * The ambient env minus credential-shaped vars, plus the caller's explicit * env. `PATH`, `HOME`, `TMPDIR`, locale, and proxy vars survive the scrub, so - * a child CLI runs normally; only {@link SENSITIVE_ENV_PATTERN}-shaped names - * are dropped. + * a child CLI runs normally; only credential-shaped names are dropped. * @param extra - explicit vars layered on top AFTER the scrub, so a * credential-shaped name supplied deliberately still reaches the child. * @returns the environment to spawn the child with. @@ -57,7 +56,7 @@ export function spawnFailure(child: ChildProcess): Promise { * already gone. * @param child - the child process to await. */ -export function waitForExit(child: ChildProcess): Promise { +function waitForExit(child: ChildProcess): Promise { if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve() return new Promise(resolve => child.once('exit', () => { resolve() })) } @@ -72,7 +71,7 @@ export function waitForExit(child: ChildProcess): Promise { * @returns `true` if the child exits within `ms` (immediately if it is * already gone), `false` on timeout. */ -export function exitsWithin(child: ChildProcess, ms: number): Promise { +function exitsWithin(child: ChildProcess, ms: number): Promise { if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true) return new Promise((resolve) => { const onExit = (): void => { diff --git a/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts b/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts index bdc0260c73..4b2552a4c6 100644 --- a/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts +++ b/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts @@ -9,10 +9,7 @@ import { buildChildEnv, createIsolatedConfigDir, disposeChildProcess, - exitsWithin, - SENSITIVE_ENV_PATTERN, spawnFailure, - waitForExit, } from '../src/index.ts' // `rm` is real-passthrough except for one deterministic failure. Permission-based recursive-rm @@ -44,6 +41,8 @@ interface FakeChildScript { diesOn?: LethalTrigger /** Delay (ms) between the lethal trigger and the exit event. */ delayMs?: number + /** Complete the scripted exit inside the triggering call. */ + synchronousExit?: boolean /** `false` models a child spawned without a stdin pipe. */ stdin?: boolean } @@ -77,11 +76,13 @@ class FakeChild extends EventEmitter { // SIGKILL is uncatchable — it always fells the child; any other trigger // only when the scenario scripts it as the lethal one. if (trigger !== 'SIGKILL' && this.script.diesOn !== trigger) return - setTimeout(() => { + const exit = (): void => { if (trigger === 'eof') this.exitCode = 0 else this.signalCode = trigger this.emit('exit', this.exitCode, this.signalCode) - }, this.script.delayMs ?? 0) + } + if (this.script.synchronousExit === true) exit() + else setTimeout(exit, this.script.delayMs ?? 0) } } @@ -90,7 +91,7 @@ function asChild(fake: FakeChild): ChildProcess { return fake as unknown as ChildProcess } -describe('buildChildEnv / SENSITIVE_ENV_PATTERN', () => { +describe('buildChildEnv', () => { it('drops credential-shaped ambient vars (KEY/SECRET/TOKEN, case-insensitive)', () => { process.env.DSH_PROC_TEST_API_KEY = 'leak' process.env.dsh_proc_test_secret = 'leak' @@ -108,7 +109,6 @@ describe('buildChildEnv / SENSITIVE_ENV_PATTERN', () => { }) it('forwards normal ambient vars', () => { - expect(SENSITIVE_ENV_PATTERN.test('PATH')).toBe(false) expect(buildChildEnv({}).PATH).toBe(process.env.PATH) }) @@ -146,7 +146,7 @@ describe('spawnFailure', () => { const fake = new FakeChild({ diesOn: 'SIGTERM' }) const failure = spawnFailure(asChild(fake)) fake.kill('SIGTERM') - await waitForExit(asChild(fake)) + await new Promise(resolve => fake.once('exit', () => { resolve() })) // A clean lifecycle emits `exit`, never `error` — the capture stays // pending forever, so a race against it is decided by the other arms. const settled = await Promise.race([ @@ -157,51 +157,6 @@ describe('spawnFailure', () => { }) }) -describe('waitForExit / exitsWithin', () => { - it('resolves immediately for a child that already exited by code', async () => { - const fake = new FakeChild() - fake.exitCode = 0 - await expect(waitForExit(asChild(fake))).resolves.toBeUndefined() - }) - - it('resolves immediately for a child that already died by signal', async () => { - const fake = new FakeChild() - fake.signalCode = 'SIGTERM' - await expect(waitForExit(asChild(fake))).resolves.toBeUndefined() - }) - - it('resolves on the exit event of a live child', async () => { - const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 }) - const exited = waitForExit(asChild(fake)) - fake.kill('SIGTERM') - await expect(exited).resolves.toBeUndefined() - expect(fake.signalCode).toBe('SIGTERM') - }) - - it('exitsWithin resolves true immediately for an already-exited child (no listener attached)', async () => { - const fake = new FakeChild() - fake.exitCode = 0 - await expect(exitsWithin(asChild(fake), 1000)).resolves.toBe(true) - expect(fake.listenerCount('exit')).toBe(0) - }) - - it('exitsWithin resolves true when the child exits inside the window', async () => { - const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 }) - fake.kill('SIGTERM') - await expect(exitsWithin(asChild(fake), 1000)).resolves.toBe(true) - // The once-listener fired and the grace timer was cleared — nothing lingers. - expect(fake.listenerCount('exit')).toBe(0) - }) - - it('exitsWithin resolves false on timeout for a child that never exits', async () => { - const fake = new FakeChild() // nothing short of SIGKILL fells it; no signal sent - await expect(exitsWithin(asChild(fake), 20)).resolves.toBe(false) - // The timeout arm removed its exit listener: repeated waits (a poll loop, - // the ladder's tiers) never accumulate listeners on the same child. - expect(fake.listenerCount('exit')).toBe(0) - }) -}) - describe('disposeChildProcess', () => { it('returns immediately for an already-exited child (no EOF, no signals)', async () => { const fake = new FakeChild() @@ -227,12 +182,28 @@ describe('disposeChildProcess', () => { expect(fake.exitCode).toBe(0) }) + it('recognizes a child that exits synchronously on stdin EOF', async () => { + const fake = new FakeChild({ diesOn: 'eof', synchronousExit: true }) + await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 }) + expect(fake.exitCode).toBe(0) + expect(fake.listenerCount('exit')).toBe(0) + }) + it('tier 2: a child that ignores EOF but honors SIGTERM dies on the middle rung', async () => { const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 }) await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }) expect(fake.stdinEnded).toBe(true) expect(fake.kills).toEqual(['SIGTERM']) expect(fake.signalCode).toBe('SIGTERM') + expect(fake.listenerCount('exit')).toBe(0) + }) + + it('recognizes a child that exits synchronously on SIGTERM', async () => { + const fake = new FakeChild({ diesOn: 'SIGTERM', synchronousExit: true }) + await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }) + expect(fake.kills).toEqual(['SIGTERM']) + expect(fake.signalCode).toBe('SIGTERM') + expect(fake.listenerCount('exit')).toBe(0) }) it('tier 3: a SIGTERM-trapping child is SIGKILLed, and dispose resolves only after the exit', async () => { @@ -244,6 +215,13 @@ describe('disposeChildProcess', () => { expect(fake.signalCode).toBe('SIGKILL') }) + it('recognizes a child already gone when the final exit wait begins', async () => { + const fake = new FakeChild({ synchronousExit: true }) + await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }) + expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL']) + expect(fake.signalCode).toBe('SIGKILL') + }) + it('walks the ladder for a child spawned without a stdin pipe', async () => { const fake = new FakeChild({ stdin: false, diesOn: 'SIGTERM', delayMs: 5 }) await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }) diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 6f1f5fa40a..17edd0e3c7 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -50,7 +50,9 @@ Runtime features are optional methods on `SubagentRun`: `sendMessage?` steers a `SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for the child resources to quiesce. -The service emits `subagent/start` only after `start()` has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. In-process start observers can resolve the published child through `ctx.agents.get(info.id)`; remote providers need not publish a local agent. +A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, and records `request.parent.session.id` in the child's `parentSession` header. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`. + +The service emits `subagent/start` only after `start()` has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. The pair shares a service-minted `runId`; its `local` flag is snapshotted from the provider's exact `localAgent`, so observers never infer run identity or locality from reusable provider/session names. Run events are scoped to the delegating parent. Every listener is independently contained: a synchronous throw or rejected returned promise is logged without starving peer listeners or changing the run. diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 0b09033fd3..aea05553e4 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -23,15 +23,19 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 0d156ae725..19779f9137 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -28,13 +28,15 @@ * @module @deepseek-ai/dsh-subagent */ +import { randomUUID } from 'node:crypto' import { Context, Service } from 'cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { Agent, AgentId } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { SessionId } from '@deepseek-ai/dsh-session' import type { SubagentCapabilities, SubagentProvider, @@ -42,7 +44,9 @@ import type { SubagentRun, SubagentStartRequest, } from './types.ts' +import { SubagentRunId } from './types.ts' +export { SubagentRunId } from './types.ts' export type { SubagentCapabilities, SubagentProvider, @@ -111,18 +115,26 @@ declare module 'cordis' { /** Observe-only identifying detail for a ready subagent run. */ export interface SubagentRunInfo { + /** Unique identity shared with the paired terminal event. */ + readonly runId: SubagentRunId /** The provider that established the run. */ readonly provider: string /** The child agent's id. */ - readonly id: AgentId + readonly id: SessionId + /** Snapshot of whether `SubagentRun.localAgent` was present when start fulfilled. */ + readonly local: boolean } /** Observe-only outcome detail for a settled subagent run. */ export interface SubagentRunEndInfo { + /** Unique identity shared with the paired start event. */ + readonly runId: SubagentRunId /** The provider that ran it. */ readonly provider: string /** The child agent's id. */ - readonly id: AgentId + readonly id: SessionId + /** Snapshot of whether `SubagentRun.localAgent` was present when start fulfilled. */ + readonly local: boolean /** The terminal stop reason. */ readonly stopReason: SubagentResult['stopReason'] /** The child's final assistant output, absent on infrastructure rejection. */ @@ -207,22 +219,28 @@ export class SubagentService extends Service { const parent = request.parent const run = await provider.start(request) + const runId = SubagentRunId(randomUUID()) + const lifecycleIdentity = { + runId, + provider: name, + id: run.id, + local: run.localAgent !== undefined, + } // Attach the terminal observer before dispatching start. Promise reactions // still run after this synchronous start emission, preserving start → end. void run.result.then( (result) => { this.emitLifecycle('subagent/end', { - provider: name, - id: run.id, + ...lifecycleIdentity, stopReason: result.stopReason, lastAssistantMessage: result.output, }, parent) }, () => { - this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }, parent) + this.emitLifecycle('subagent/end', { ...lifecycleIdentity, stopReason: 'error' }, parent) }, ) - this.emitLifecycle('subagent/start', { provider: name, id: run.id }, parent) + this.emitLifecycle('subagent/start', lifecycleIdentity, parent) return run } diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 05bb40d575..1b1645d89b 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -6,10 +6,24 @@ * @module @deepseek-ai/dsh-subagent/types */ -import type { Agent, AgentId, AgentOptions } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' +import type { Branded } from '@deepseek-ai/dsh-brand' import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SessionId } from '@deepseek-ai/dsh-session' import type { StructuredOutputSchema, ToolRestriction } from '@deepseek-ai/dsh-tools' +/** Identifies one accepted subagent run across its lifecycle event pair. */ +export type SubagentRunId = Branded<'SubagentRunId'> + +/** + * Brand a string as a {@link SubagentRunId}. + * @param id - the raw id string (the service mints UUIDs; tests may pass fixtures). + * @returns the same string, branded. + */ +export function SubagentRunId(id: string): SubagentRunId { + return id as SubagentRunId +} + /** * Which START-TIME features a provider supports. Checked by the service before delegating to * {@link SubagentProvider.start}: a request that needs a capability the chosen provider lacks @@ -132,8 +146,18 @@ export interface SubagentResult { * capability discovery; narrow their presence before calling. */ export interface SubagentRun { - /** The child agent's id (local in-process runs are already published in `ctx.agents`; remote transports need not publish locally). */ - readonly id: AgentId + /** + * Parent-scoped run id. For a local run, this MUST equal the published child + * session id, whose `parentSession` records `request.parent.session.id`; a + * remote provider mints an id unique in the parent namespace. + */ + readonly id: SessionId + /** + * The exact published in-process child, or `undefined` for a remote run. + * When present, its id is {@link id}; the provider retains no ownership + * implication beyond the run's ordinary {@link dispose} contract. + */ + readonly localAgent: Agent | undefined /** * Resolves with the child's terminal {@link SubagentResult} when the run * settles. Does NOT reject on a child-level failure — a model/transport diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 3d10b425ed..66008a923b 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import { type Agent } from '@deepseek-ai/dsh-agent' + import { HarnessError } from '@deepseek-ai/dsh-llm' import { carrierKeyOf } from '@deepseek-ai/dsh-scope' import SubagentService, { @@ -12,9 +13,10 @@ import SubagentService, { type SubagentRun, type SubagentStartRequest, } from '@deepseek-ai/dsh-subagent' +import { SessionId } from '@deepseek-ai/dsh-session' function fakeParent(id = 'parent-1'): Agent { - return { id: AgentId(id) } as unknown as Agent + return { id: SessionId(id) } as unknown as Agent } const ALL_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true } @@ -45,7 +47,8 @@ class StubProvider implements SubagentProvider { async start(request: SubagentStartRequest): Promise { this.startCount += 1 return { - id: AgentId(`child:${this.name}:${request.parent.id}`), + id: SessionId(`child:${this.name}:${request.parent.id}`), + localAgent: undefined, result: Promise.resolve(this.outcome), async dispose() {}, } @@ -135,13 +138,14 @@ describe('SubagentService', () => { const parent = fakeParent('delegator') const events: string[] = [] const keys: unknown[] = [] - ctx.on('subagent/start', function () { events.push('start'); keys.push(carrierKeyOf(this)) }) - ctx.on('subagent/end', function () { events.push('end'); keys.push(carrierKeyOf(this)) }) + const runIds: string[] = [] + ctx.on('subagent/start', function (info) { events.push('start'); keys.push(carrierKeyOf(this)); runIds.push(info.runId) }) + ctx.on('subagent/end', function (info) { events.push('end'); keys.push(carrierKeyOf(this)); runIds.push(info.runId) }) const starting = subagents.start('deferred', baseRequest({ parent })) await Promise.resolve() expect(events).toEqual([]) - ready.resolve({ id: AgentId('child'), result: result.promise, async dispose() {} }) + ready.resolve({ id: SessionId('child'), localAgent: undefined, result: result.promise, async dispose() {} }) const run = await starting expect(events).toEqual(['start']) result.resolve({ output: [{ type: 'text', text: 'answer' }], stopReason: 'completed' }) @@ -149,6 +153,21 @@ describe('SubagentService', () => { await Promise.resolve() expect(events).toEqual(['start', 'end']) expect(keys).toEqual([parent, parent]) + expect(runIds[0]).toBe(runIds[1]) + }) + + it('mints distinct lifecycle identities when provider and child ids repeat', async () => { + const { ctx, subagents } = await service() + subagents.registerProvider(new StubProvider('reused')) + const runIds: string[] = [] + ctx.on('subagent/start', info => void runIds.push(info.runId)) + + const first = await subagents.start('reused', baseRequest()) + const second = await subagents.start('reused', baseRequest()) + await Promise.all([first.result, second.result]) + + expect(runIds).toHaveLength(2) + expect(new Set(runIds).size).toBe(2) }) it('emits no run lifecycle when provider startup rejects', async () => { @@ -190,7 +209,7 @@ describe('SubagentService', () => { capabilities: NO_CAPS, inheritsParentContext: false, async start() { - return { id: AgentId('infra-child'), result: failure.promise, async dispose() {} } + return { id: SessionId('infra-child'), localAgent: undefined, result: failure.promise, async dispose() {} } }, }) const failedRun = await subagents.start('infra', baseRequest()) diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index fdfaacef08..f46f3dda2c 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -161,13 +161,13 @@ export async function settleRun(run: SubagentRun): Promise { * A fresh child needs a standalone prompt; a forked child already sees the * conversation's completed turns — telling the model to restate everything * (or, worse, that the child "does not see this conversation") would be false - * for a fork. Exported for tests. + * for a fork. * @param inheritsConversation - whether the child's conversation is seeded * with the parent's completed turns; this says nothing about tool, service, * scope, or authority inheritance. * @returns the tool `description` and the `prompt` parameter description. */ -export function providerWording(inheritsConversation: boolean): { description: string; promptDescription: string } { +function providerWording(inheritsConversation: boolean): { description: string; promptDescription: string } { if (inheritsConversation) { return { description: diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 90f84bfceb..3bd42ae299 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -4,7 +4,7 @@ import Loader from '@cordisjs/plugin-loader' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import { type Agent } from '@deepseek-ai/dsh-agent' import AgentRegistry from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' import TaskService from '@deepseek-ai/dsh-tasks' @@ -12,6 +12,7 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import * as mock from '@deepseek-ai/dsh-subagent-mock' import * as tool from '../src/index.ts' import { runOutcome, settleRun } from '../src/index.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * Drives the REAL plugin body: mounts `dsh-tool-subagent` on a real @@ -24,7 +25,7 @@ import { runOutcome, settleRun } from '../src/index.ts' /** A minimal parent Agent — the tool reads `agent.id` for `parent`. */ function fakeAgent(id = 'parent-1'): Agent { - return { id: AgentId(id) } as unknown as Agent + return { id: SessionId(id) } as unknown as Agent } async function setup(toolConfig: tool.Config, mockConfig: Partial = {}) { @@ -85,7 +86,7 @@ describe('dsh-tool-subagent', () => { // Schema omission is advertising, not enforcement: the arg validator // allows undeclared keys, so the opt-out must also hold in execute(). const ctx = await setup({ provider: 'mock', enableRunInBackground: false }) - const parent = { id: AgentId('agent-sess-off'), inject: () => {}, session: { header: { version: 0, id: 'sess-off', createdAt: 0 } } } as unknown as Agent + const parent = { id: SessionId('sess-off'), inject: () => {}, session: { header: { version: 0, id: 'sess-off', createdAt: 0 } } } as unknown as Agent const forced = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true }, { agent: parent }) expect(forced.isError).toBe(true) @@ -156,7 +157,8 @@ describe('dsh-tool-subagent', () => { capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => ({ - id: AgentId('weird-child'), + id: SessionId('weird-child'), + localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'partial' }], stopReason: 'frobnicated' as never }), dispose: async () => {}, }), @@ -183,7 +185,8 @@ describe('dsh-tool-subagent', () => { start: async (request) => { seen = request return { - id: AgentId('capture-child'), + id: SessionId('capture-child'), + localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => {}, } @@ -212,7 +215,8 @@ describe('dsh-tool-subagent', () => { start: async (request) => { seen = request return { - id: AgentId('bare-child'), + id: SessionId('bare-child'), + localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => {}, } @@ -339,7 +343,8 @@ describe('dsh-tool-subagent', () => { capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => ({ - id: AgentId('spy-child'), + id: SessionId('spy-child'), + localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => void disposed(), }), @@ -361,7 +366,8 @@ describe('dsh-tool-subagent', () => { capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => ({ - id: AgentId('spy-child'), + id: SessionId('spy-child'), + localAgent: undefined, result: Promise.resolve({ output: [], stopReason: 'error' as const }), dispose: async () => void disposed(), }), @@ -392,7 +398,8 @@ describe('dsh-tool-subagent', () => { resolveResult({ output: [], stopReason: 'aborted' }) }, { once: true }) return { - id: AgentId('spy-child'), + id: SessionId('spy-child'), + localAgent: undefined, result, dispose: async () => {}, } @@ -484,7 +491,8 @@ describe('dsh-tool-subagent', () => { start: async (request) => { seen = request return { - id: AgentId('capture2-child'), + id: SessionId('capture2-child'), + localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => {}, } @@ -541,7 +549,8 @@ describe('dsh-tool-subagent', () => { start: async (request) => { seen = request return { - id: AgentId('capture3-child'), + id: SessionId('capture3-child'), + localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => {}, } @@ -570,7 +579,8 @@ describe('dsh-tool-subagent', () => { start: async (request) => { seen = request return { - id: AgentId('capture4-child'), + id: SessionId('capture4-child'), + localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => {}, } @@ -602,11 +612,12 @@ describe('dsh-tool-subagent background mode', () => { /** A live parent with a dedicated scope fiber for structural task cleanup. */ function ownerAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Agent { const scopeFiber = ctx.plugin(() => {}) + const id = SessionId(sessionId) const agent = { - id: AgentId(`agent-${sessionId}`), + id, ctx: scopeFiber.ctx, inject, - session: { header: { version: 0, id: sessionId, createdAt: 0 } }, + session: { id, header: { version: 0, id, createdAt: 0 } }, } as unknown as Agent ctx.agents.register(agent) return agent @@ -736,7 +747,7 @@ describe('dsh-tool-subagent background mode', () => { inheritsParentContext: false, start: async (request) => { let settle!: (value: { output: { type: 'text'; text: string }[]; stopReason: 'aborted' }) => void - const id = AgentId(`hang-${++starts}`) + const id = SessionId(`hang-${++starts}`) const result = new Promise<{ output: { type: 'text'; text: string }[]; stopReason: 'aborted' }>((res) => { settle = res }) request.signal.addEventListener('abort', () => { cancels.push(typeof request.signal.reason === 'string' ? request.signal.reason : undefined) @@ -744,6 +755,7 @@ describe('dsh-tool-subagent background mode', () => { }, { once: true }) return { id, + localAgent: undefined, result, dispose: () => Promise.resolve(), } @@ -782,7 +794,8 @@ describe('dsh-tool-subagent background mode', () => { it('settleRun disposes the run before reporting, on both result paths', async () => { const order: string[] = [] const completed = await settleRun({ - id: AgentId('child-1'), + id: SessionId('child-1'), + localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text' as const, text: 'ok' }], stopReason: 'completed' as const }), dispose() { order.push('dispose'); return Promise.resolve() }, }) @@ -793,7 +806,8 @@ describe('dsh-tool-subagent background mode', () => { // An infrastructure rejection still disposes and reports failed. let disposed = false const failed = await settleRun({ - id: AgentId('child-2'), + id: SessionId('child-2'), + localAgent: undefined, result: Promise.reject(new Error('transport gone')), dispose() { disposed = true; return Promise.resolve() }, }) @@ -801,14 +815,16 @@ describe('dsh-tool-subagent background mode', () => { expect(disposed).toBe(true) const disposeFailed = await settleRun({ - id: AgentId('child-3'), + id: SessionId('child-3'), + localAgent: undefined, result: Promise.resolve({ output: [], stopReason: 'completed' }), dispose: () => Promise.reject(new Error('reap failed')), }) expect(disposeFailed).toEqual({ status: 'failed', detail: 'dispose failed: Error: reap failed' }) const bothFailed = await settleRun({ - id: AgentId('child-4'), + id: SessionId('child-4'), + localAgent: undefined, result: Promise.reject(new Error('result failed')), dispose: () => Promise.reject(new Error('reap failed')), }) @@ -826,11 +842,12 @@ describe('background preflight failure (no orphaned child, by construction)', () await ctx.plugin(AgentRegistry) await ctx.plugin(TaskService) const scopeFiber = ctx.plugin(() => {}) + const id = SessionId('sess-p') const parent = { - id: AgentId('agent-sess-p'), + id, ctx: scopeFiber.ctx, inject: () => {}, - session: { header: { version: 0, id: 'sess-p', createdAt: 0 } }, + session: { id, header: { version: 0, id, createdAt: 0 } }, } as unknown as Agent ctx.agents.register(parent) @@ -842,7 +859,8 @@ describe('background preflight failure (no orphaned child, by construction)', () start: async () => { starts += 1 return { - id: AgentId('probe-child'), + id: SessionId('probe-child'), + localAgent: undefined, result: Promise.resolve({ output: [], stopReason: 'completed' as const }), dispose: () => Promise.resolve(), } diff --git a/packages/support/README.md b/packages/support/README.md index a85fffcdac..0d991f07cf 100644 --- a/packages/support/README.md +++ b/packages/support/README.md @@ -4,11 +4,11 @@ Packages that exist to serve development, testing, and the examples rather than | Package | Role | ctx key | |---|---|---| -| `acp-snapshot/` | ACP snapshot suite kit: subprocess scenario harness + golden normalizers + the `defineAcpSnapshotSuite` factory | (library — imported by example `*.snapshot.ts` suites) | +| `acp-snapshot/` | ACP test kit: shared subprocess/client launcher + snapshot harness, normalizers, and suite factory | (library — imported by ACP e2e and `*.snapshot.ts` suites) | | `agent-loop-testkit/` | Shared prerequisite mounting for tests that exercise the concrete agent loop | (library — imported by AgentLoop integration tests) | | `invariants/` | Runtime event-contract assertions for development diagnostics | (listens on `session/*`, `agent/*`) | | `loader-smoke/` | Shared real-Loader subprocess harness for keyless example smokes | (library — imported by example e2e suites) | | `llm-replay/` | Record/replay adapter: short-circuits `llm/stream` from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) | | `subagent-mock/` | Scripted `SubagentProvider` for deterministic seam/tool tests | (registers on `ctx.subagents`) | -`invariants` is development support but has no environment guard: it runs wherever registered, and the default `dsh-agent-spine-demo` bundle mounts it unconditionally. `agent-loop-testkit` centralizes the mandatory service spine for hand-built AgentLoop tests without owning their loop or scenario. `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the snapshot tier's harness/normalizer/suite machinery, while `loader-smoke` owns the parallel stdio/Loader process boundary used by keyless example e2e suites. `subagent-mock` exercises the real `ctx.subagents` load path without a model or child agent. A package graduates OUT of `support/` into a product group only when it gains documented product consumers. +`invariants` is development support but has no environment guard: it runs wherever registered, and the default `dsh-agent-spine-demo` bundle mounts it unconditionally. `agent-loop-testkit` centralizes the mandatory service spine for hand-built AgentLoop tests without owning their loop or scenario. `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the ACP subprocess/client boundary plus the snapshot harness, normalizers, and suite machinery, while `loader-smoke` owns the parallel stdio/Loader process boundary used by keyless example e2e suites. `subagent-mock` exercises the real `ctx.subagents` load path without a model or child agent. A package graduates OUT of `support/` into a product group only when it gains documented product consumers. diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 222d572043..27e0012d19 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -2,11 +2,12 @@ The ACP snapshot suite kit: the shared machinery behind the keyless snapshot tier (`pnpm run test:snapshot`, [testing policy](../../../docs/testing.md)). An example gets a full snapshot suite from a scenario table plus a fixtures directory; every compare/guard mechanic lives here, under the per-file coverage gate, instead of being copied per example. -Three layers, importable separately: +Four layers, importable separately: +- **`launchAcpTestAgent` (launcher)** — boots an unbuilt ACP agent from a temp cwd, pins tsx to the repo tsconfig, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through its startup lifecycle, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. - **`runScenario` (harness)** — boots the real agent bin as a subprocess via tsx (unbuilt, Loader path), drives it over ACP JSON-RPC stdio from a deterministic `input.json` script, tees raw stdout for the golden + purity check, and harvests every persisted session JSONL (parent + subagent children, primary-first) after a graceful stdin-EOF shutdown. Parameterized by `AgentUnderTest` (`binScript`, `configPath`, `tsconfigPath` — absolute paths; the subprocess cwd is a temp dir outside the repo). Startup failures preserve captured agent stderr in the rejected diagnostic. - **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header RFC](../../../docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). -- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario golden + re-persisted-log compares, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.golden.md` plus `tool-schemas.golden.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Must be called at vitest collection time. +- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario golden + re-persisted-log compares, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.golden.md` plus `tool-schemas.golden.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. A consuming `*.snapshot.ts` is the scenario table plus one factory call: @@ -37,9 +38,9 @@ defineAcpSnapshotSuite({ A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. Each pinning directory stores the normalized full prompt sequence in generated `system-prompt.golden.md` and the corresponding full tool-schema sequence in generated `tool-schemas.golden.json`; `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`, which fixes the length of both sidecar sequences. -Examples use a `cordis.snapshot.yml` overlay with [`dsh-llm-replay`](../llm-replay/README.md). Recording calls the live model and updates model fixtures; keyless refresh replays those fixtures and updates derived stdout, session-log, prompt, and tool-schema snapshots. See the [snapshot RFC](../../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md). +The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config RFC](../../../docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log goldens, and each pin's prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot RFC](../../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md). -`suite.ts` imports Vitest, so use this package only inside a Vitest run. The ACP-specific script queues permission answers by stable option kind and maps them to current option ids; a missing answer cancels, while an unavailable kind fails the scenario after cancelling the agent request. It can also set session config options or assert that unknown ids and values are rejected in the transcript. +Constraints: `suite.ts` imports vitest, so the package entry is importable only inside a vitest run (the launcher, harness, and normalizers have no such dependency but ship from the same entry). ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection`. Permission round-trips are scriptable: `InputScript.permissionAnswers` is a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) the client maps to the agent-issued `optionId` at answer time; an absent or exhausted queue answers `cancelled`, and a kind the request never offered rejects the run (the agent is answered `cancelled`, so a tolerant agent cannot absorb the scenario bug). Session config options are scriptable too: the `setConfigOption` step switches a knob over `session/set_config_option`, and `setConfigOptionExpectError` asserts the bridge rejects an unknown id or out-of-vocabulary value (the error frame stays in the transcript). ## Model Experience diff --git a/packages/support/acp-snapshot/package.json b/packages/support/acp-snapshot/package.json index eb6b46deaf..64e1b0cbd7 100644 --- a/packages/support/acp-snapshot/package.json +++ b/packages/support/acp-snapshot/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-acp-snapshot", - "description": "ACP snapshot suite kit: real-subprocess scenario harness, golden normalizers, and the suite factory behind the keyless snapshot tier", + "description": "ACP test kit: shared subprocess launcher, snapshot scenario harness, golden normalizers, and suite factory", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index e2072b6b17..e3efca8da9 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -1,59 +1,47 @@ /** - * Shared ACP snapshot subprocess harness. It boots the real agent bin through the Cordis - * loader, drives deterministic ACP JSON-RPC over stdio, captures protocol-pure stdout, and - * harvests persisted session logs after graceful shutdown. Normalization stays in - * `normalize.ts`; suite registration stays in `suite.ts`. + * Shared subprocess harness for ACP snapshot suites. A library module driven by + * the suite factory in ./suite.ts (and directly by harness-level specs); each + * example's `*.snapshot.ts` names its own agent-under-test paths. + * + * It boots the REAL agent bin subprocess via the cordis Loader (so the + * export-shape bug class stays guarded — see docs/postmortem/0001), drives it + * over real ACP JSON-RPC stdio with a deterministic input script, tees raw + * stdout (for the golden + a purity check) into an SDK `ClientSideConnection`, + * and — in record mode — harvests the persisted session JSONL after a graceful + * shutdown flush. The pure normalizers in ./normalize.ts turn the captured + * stdout frames and the session-log events into stable, snapshot-able text. + * + * See docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md. + * * @module @deepseek-ai/dsh-acp-snapshot/harness */ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' import { cp, mkdtemp, readFile, readdir, rm } from 'node:fs/promises' import { existsSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, delimiter } from 'node:path' -import { Readable, Writable } from 'node:stream' import { ClientSideConnection, - ndJsonStream, PROTOCOL_VERSION, - type Agent as AcpAgent, - type Client, type RequestPermissionRequest, type RequestPermissionResponse, type SessionNotification, } from '@agentclientprotocol/sdk' -import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' +import { launchAcpTestAgent, type AgentUnderTest, type LaunchedAcpTestAgent } from './launcher.ts' + +export type { AgentUnderTest } from './launcher.ts' /** - * The agent composition a scenario runs against: which bin to boot and which - * leaf config it loads. All paths are ABSOLUTE — the subprocess cwd is a temp - * dir outside the repo, so relative resolution would miss; a suite resolves - * them from its own `import.meta.url`. - */ -export interface AgentUnderTest { - /** The agent bin's SOURCE entry (e.g. `packages/examples/acp-demo/src/bin.ts`); the `lib` bin is derived from it. */ - binScript: string - /** Explicit plain-Node entry for `lib` mode; intended for test fixtures outside a package `src/` tree. */ - libBinScript?: string | undefined - /** - * The example's live `cordis.yml`. Under `DSH_SNAPSHOT=replay` the bin swaps - * it for the sibling `cordis.snapshot.yml` (the keyless replay overlay), so - * one path serves both modes. - */ - configPath: string - /** - * The repo-root tsconfig whose `paths` map resolves the unbuilt workspace - * imports in `src` mode (passed to the child as `TSX_TSCONFIG_PATH`). Ignored - * in `lib` mode, where the example resolves plugins through real `exports`. - */ - tsconfigPath: string -} - -/** - * One step of a scenario's deterministic input script (`input.json`). The harness interprets - * these in order. `newSession` captures the server-issued (random) session id into a - * `{{sessionId}}` variable that later steps reference. `promptAndCancel` sends without awaiting, - * waits for the first streamed message, then cancels, making transcript order deterministic. + * One step of a scenario's deterministic input script (`input.json`). The + * harness interprets these in order. `newSession` captures the server-issued + * (random) session id into a `{{sessionId}}` variable that later steps + * reference, since a committed file cannot know the id in advance. + * + * `promptAndCancel` sends a prompt WITHOUT awaiting its response, waits until + * the client observes the first streamed `agent_message_chunk` (so the emitted + * frames deterministically precede the cancellation), then cancels the turn — + * the only way to exercise a cancel deterministically (a plain `prompt` step + * awaits the response, which a cancel/hang scenario would block on forever). */ export type InputStep = | { op: 'initialize'; terminalOutput?: boolean } @@ -70,9 +58,16 @@ export type InputStep = export interface InputScript { steps: InputStep[] /** - * FIFO permission answers selected by stable option kind; the harness maps each kind to the - * agent-issued option id. Exhaustion cancels, while a kind the agent did not offer fails the - * scenario. + * Ordered answers for the agent's `session/request_permission` round-trips, + * consumed FIFO — the Nth request gets the Nth answer. Each answer selects + * by option KIND: option ids are agent-issued randoms a committed script + * cannot know, while kinds are the ACP-stable vocabulary, so the client maps + * kind → the offered `optionId` at answer time. A request beyond the queue + * (or with no queue at all) is answered `cancelled` — the stub behavior a + * scenario without approvals relies on. A scripted kind the request does + * not offer REJECTS the run: the scenario scripted an impossible click, + * and {@link runScenario} throws once the in-flight step settles (the + * agent itself just sees `cancelled`, so it cannot absorb the bug). */ permissionAnswers?: PermissionAnswer[] } @@ -165,93 +160,47 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise // Fixed path length: spill-policy budgets the preview against the REAL path // before stdout normalization, so tmpdir() length differences churn goldens. const spillRoot = '/tmp/dsh-acp-snapshot-spill' - // Everything past the temp-dir creation runs under a try/finally that always - // removes both dirs — so a failure in workspace seeding, spawn, or any step - // never leaks them (the "e2e tests own their resources" rule). - let child: ChildProcessWithoutNullStreams | undefined + // Everything past the temp-dir creation is followed by failure-safe cleanup, + // so a failure in workspace seeding, spawn, or any step never leaks resources. + let launched: LaunchedAcpTestAgent | undefined let sessionId: string | undefined let sessionLogs: HarvestedLog[] = [] - const rawBuffers: Buffer[] = [] - const stderrChunks: string[] = [] - try { + const outcome = await (async (): Promise => { // Seed the workspace if the scenario ships one (a file the agent reads/edits). + // Copied into the temp cwd so the agent's bash tools see it; the goldens + // normalize the cwd, so the seeded paths stay stable across runs. if (opts.workspaceDir !== undefined && existsSync(opts.workspaceDir)) { await cp(opts.workspaceDir, cwd, { recursive: true }) } - // Boot the agent in the environment's mode (DSH_EXAMPLE_MODE): `src` runs the - // source bin under tsx with the paths map; `lib` runs the built bin under plain - // Node, resolving plugins through the example's workspace node_modules → lib. - const launch = resolveExampleLaunch({ - srcBin: opts.agent.binScript, - libBin: opts.agent.libBinScript, - configArgs: ['--config', opts.configPath ?? opts.agent.configPath], - tsconfigPath: opts.agent.tsconfigPath, - env: { - DSH_SNAPSHOT: opts.mode, - DSH_SNAPSHOT_FILE: opts.fixtureFile, - DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot, - DSH_SNAPSHOT_SPILL_ROOT: spillRoot, - DSH_HOME: join(cwd, '.dsh'), - DSH_AGENTS_HOME: join(cwd, '.agents'), - ...opts.overrideFile !== undefined ? { DSH_SNAPSHOT_OVERRIDE: opts.overrideFile } : {}, - ...opts.childFiles !== undefined && opts.childFiles.length > 0 - ? { DSH_SNAPSHOT_CHILD_FILES: opts.childFiles.join(delimiter) } - : {}, - }, - }) - - child = spawn( - launch.command, - launch.args, - { cwd, env: { ...process.env, ...launch.env }, stdio: ['pipe', 'pipe', 'pipe'] }, - ) - - child.stderr.setEncoding('utf8') - child.stderr.on('data', (c: string) => stderrChunks.push(c)) - - // Tee the same raw bytes to the golden and SDK client. Decode once at the end so a UTF-8 - // sequence split across stream chunks cannot corrupt the transcript. - const passthrough = new Readable({ read() {} }) - child.stdout.on('data', (buf: Buffer) => { - rawBuffers.push(buf) - passthrough.push(buf) - }) - child.stdout.on('end', () => passthrough.push(null)) - - const stream = ndJsonStream( - Writable.toWeb(child.stdin) as WritableStream, - Readable.toWeb(passthrough) as ReadableStream, - ) - // Watcher so a step can block until the client OBSERVES a particular - // session/update — used by promptAndCancel to pin frame order (send cancel - // only after the streamed agent_message_chunk has arrived, so those frames - // deterministically precede the cancelled prompt response). - const updateWaiters: { match: (u: SessionNotification['update']) => boolean; resolve: () => void }[] = [] - const waitForUpdate = (match: (u: SessionNotification['update']) => boolean): Promise => - new Promise(resolve => updateWaiters.push({ match, resolve })) + const env: NodeJS.ProcessEnv = { + DSH_SNAPSHOT: opts.mode, + DSH_SNAPSHOT_FILE: opts.fixtureFile, + DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot, + DSH_SNAPSHOT_SPILL_ROOT: spillRoot, + DSH_HOME: join(cwd, '.dsh'), + DSH_AGENTS_HOME: join(cwd, '.agents'), + ...opts.overrideFile !== undefined ? { DSH_SNAPSHOT_OVERRIDE: opts.overrideFile } : {}, + ...opts.childFiles !== undefined && opts.childFiles.length > 0 + ? { DSH_SNAPSHOT_CHILD_FILES: opts.childFiles.join(delimiter) } + : {}, + } // Permission answers are consumed FIFO across the whole run; exhaustion // falls back to `cancelled` so approval-free scenarios keep the plain stub. const permissionQueue = [...input.permissionAnswers ?? []] - // A callback throw would become only an RPC error the agent could absorb. Record an - // impossible permission choice here, answer cancelled, and fail the outer scenario. + // A scenario bug detected inside a client callback (a scripted permission + // kind the agent never offered). It cannot fail the run from in there: a + // callback throw only becomes a JSON-RPC error RESPONSE to the agent, and + // a tolerant agent treats that as a denial and carries on — the run (or + // worse, a record) would absorb the impossible click silently. So the + // callback answers `cancelled` (a well-defined path for the agent), + // captures the error here, and the step loop fails the run on it. let scriptError: Error | undefined - const makeClient = (_agent: AcpAgent): Client => ({ - sessionUpdate(params: SessionNotification): Promise { - for (let i = updateWaiters.length - 1; i >= 0; i--) { - const waiter = updateWaiters[i] - // The index is always in-bounds (i only decreases; splice removes at - // i, so lower entries stay valid); the guard satisfies - // noUncheckedIndexedAccess. - /* v8 ignore next 1 -- unreachable in-bounds guard, see above */ - if (waiter === undefined) continue - if (waiter.match(params.update)) { - updateWaiters.splice(i, 1) - waiter.resolve() - } - } - return Promise.resolve() - }, + launched = launchAcpTestAgent({ + agent: opts.agent, + cwd, + ...opts.configPath !== undefined ? { configPath: opts.configPath } : {}, + env, requestPermission(params: RequestPermissionRequest): Promise { const answer = permissionQueue.shift() if (answer === undefined) return Promise.resolve({ outcome: { outcome: 'cancelled' } }) @@ -269,10 +218,12 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise return Promise.resolve({ outcome: { outcome: 'selected', optionId: option.optionId } }) }, }) - const client = new ClientSideConnection(makeClient, stream) + const active = launched + await active.spawned + const { client } = active for (const step of input.steps) { - await runStep(client, step, cwd, waitForUpdate, () => sessionId, (id) => { sessionId = id }) + await runStep(client, step, cwd, match => active.waitForUpdate(match), () => sessionId, (id) => { sessionId = id }) // A permission exchange happens while a step's request is in flight, so // by the time the step settles any script bug it exposed is captured — // fail the run HERE, as a harness error, rather than hoping the agent's @@ -281,35 +232,57 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise } // Done driving: close stdin so the server disposes gracefully (flushing // persistence) and exits. Then await exit so the harvested log is complete. - child.stdin.end() - await waitForExit(child) + await active.close() // Harvest EVERY persisted log (parent + any subagent children) while the // temp dirs still exist, ordered primary-first. sessionLogs = await harvestSessionLogs(sessionsRoot) - } catch (error: unknown) { - const stderr = stderrChunks.join('') - if (stderr === '') throw error - throw new Error(`snapshot-harness: scenario failed: ${String(error)}\nagent stderr:\n${stderr}`, { cause: error }) - } finally { - // Failure-safe teardown: kill a still-running child and drop the temp dirs - // even if seeding/spawn/a step/harvest threw, so a flaky run never leaks a - // process or dir. `child` is undefined only if spawn itself threw. - if (child !== undefined && child.exitCode === null && child.signalCode === null) { - child.kill('SIGKILL') - await waitForExit(child) + return { + rawStdout: launched.rawStdout(), + stderr: launched.stderr(), + cwd, + ...sessionId !== undefined ? { sessionId } : {}, + sessionLogs, } - await rm(cwd, { recursive: true, force: true }) - await rm(sessionsRoot, { recursive: true, force: true }) - await rm(spillRoot, { recursive: true, force: true }) - } + })().then( + value => ({ status: 'fulfilled', value } as const), + (error: unknown) => { + const stderr = launched?.stderr() ?? '' + return { + status: 'rejected', + error: stderr === '' + ? error + : new Error(`snapshot-harness: scenario failed: ${String(error)}\nagent stderr:\n${stderr}`, { cause: error }), + } as const + }, + ) - return { - rawStdout: Buffer.concat(rawBuffers).toString('utf8'), - stderr: stderrChunks.join(''), - cwd, - ...sessionId !== undefined ? { sessionId } : {}, - sessionLogs, + // Failure-safe teardown: wait for a still-running child, then attempt every + // owned-path removal even when an earlier cleanup rejects. Report every + // teardown failure alongside a scenario failure so neither orthogonal + // outcome hides the other. + const cleanupResults: PromiseSettledResult[] = [] + const cleanup = async (action: () => Promise): Promise => { + cleanupResults.push(...await Promise.allSettled([action()])) } + /* v8 ignore next 1 -- launch itself can only throw on a defensive synchronous spawn API failure */ + await cleanup(() => launched?.close('SIGKILL') ?? Promise.resolve()) + await cleanup(() => rm(cwd, { recursive: true, force: true })) + await cleanup(() => rm(sessionsRoot, { recursive: true, force: true })) + await cleanup(() => rm(spillRoot, { recursive: true, force: true })) + + const cleanupFailures = cleanupResults + .filter((result): result is PromiseRejectedResult => result.status === 'rejected') + .map(result => result.reason as unknown) + if (cleanupFailures.length > 0) { + throw new AggregateError( + outcome.status === 'rejected' ? [outcome.error, ...cleanupFailures] : cleanupFailures, + outcome.status === 'rejected' + ? 'snapshot scenario and cleanup failed' + : 'snapshot cleanup failed', + ) + } + if (outcome.status === 'rejected') throw outcome.error + return outcome.value } /** Drive one input step over the client connection. */ @@ -317,7 +290,7 @@ async function runStep( client: ClientSideConnection, step: InputStep, cwd: string, - waitForUpdate: (match: (u: SessionNotification['update']) => boolean) => Promise, + waitForUpdate: (match: (u: SessionNotification['update']) => boolean) => Promise, getSessionId: () => string | undefined, setSessionId: (id: string) => void, ): Promise { @@ -334,8 +307,10 @@ async function runStep( return } case 'newSessionExpectError': { - // The bridge rejects a session/new that widens the workspace scope (non-empty - // additionalDirectories / mcpServers — unimplemented). + // The bridge rejects a session/new that widens the workspace scope + // (non-empty additionalDirectories / mcpServers — unimplemented). The SDK + // surfaces that as a rejected RPC; swallow it so the run completes and the + // error frame is captured in the transcript. await client.newSession({ cwd, mcpServers: [], @@ -355,8 +330,10 @@ async function runStep( case 'promptExpectError': { const sessionId = getSessionId() if (sessionId === undefined) throw new Error('snapshot-harness: promptExpectError before newSession') - // The model fails this turn (a recorded provider error), so the bridge answers the prompt - // with a JSON-RPC error and the SDK rejects. + // The model fails this turn (a recorded provider error), so the bridge + // answers the prompt with a JSON-RPC error and the SDK rejects. That + // rejection IS the expected editor experience — swallow it so the run + // completes and the stdout transcript (the error frame) is captured. await client.prompt({ sessionId, prompt: [{ type: 'text', text: step.text }] }) .then(() => { throw new Error('snapshot-harness: expected the prompt to fail but it succeeded') }, () => { /* expected: the turn failed and the bridge returned an error */ }) @@ -365,8 +342,13 @@ async function runStep( case 'promptAndCancel': { const sessionId = getSessionId() if (sessionId === undefined) throw new Error('snapshot-harness: promptAndCancel before newSession') - // A hang fixture never resolves alone. Wait for its streamed chunk before cancellation - // so updates deterministically precede the cancelled prompt response. + // Dispatch the prompt WITHOUT awaiting (a hang fixture never resolves on + // its own). To pin frame order deterministically, wait until the client + // has OBSERVED the hang's streamed agent_message_chunk before cancelling — + // so those update frames always precede the cancelled prompt response in + // the transcript (without this, the late chunk and the response race). + // Then cancel and await the prompt, which the bridge settles as + // `cancelled` once the abort propagates. const promptDone = client.prompt({ sessionId, prompt: [{ type: 'text', text: step.text }] }) await waitForUpdate(u => u.sessionUpdate === 'agent_message_chunk') await client.cancel({ sessionId }) @@ -402,16 +384,6 @@ async function runStep( } } -/** Resolve once the child process exits (any code/signal). */ -function waitForExit(child: ChildProcessWithoutNullStreams): Promise { - // Race guard: both call sites run within one synchronous frame of - // stdin.end()/kill(), so the exit event cannot have been delivered yet; - // kept for any future caller that awaits in between. - /* v8 ignore next 1 -- unreachable race guard, see above */ - if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve() - return new Promise(resolve => child.once('exit', () => { resolve() })) -} - /** * Harvest EVERY persisted `.jsonl` session log under a sessions root, parse each * header line, and return them ordered primary-first: the top-level session (no @@ -452,8 +424,14 @@ async function harvestSessionLogs(root: string): Promise { }) } } - // Match replay fixture assignment: primary first, then children by creation time, with id as - // a deterministic collision tiebreaker. + // Primary (no parentSession) first, then children by ascending createdAt. A + // scenario has exactly one top-level session. In the synchronous cut sibling + // children are created strictly sequentially, so their createdAt values are + // strictly ordered; the recordedId tiebreak only keeps a degenerate + // same-millisecond collision (unreachable here) deterministic. This harvest + // order must match the replay load order in dsh-llm-replay's loadSessionScripts + // so session..jsonl maps to the same child on record and replay — replay + // re-sorts childFiles by the same key, so the two stay consistent. logs.sort((a, b) => { const ap = a.parentSession === undefined ? 0 : 1 const bp = b.parentSession === undefined ? 0 : 1 diff --git a/packages/support/acp-snapshot/src/index.ts b/packages/support/acp-snapshot/src/index.ts index bdf8cccaf7..b7267febe7 100644 --- a/packages/support/acp-snapshot/src/index.ts +++ b/packages/support/acp-snapshot/src/index.ts @@ -1,13 +1,23 @@ /** - * ACP snapshot suite kit: subprocess scenario harness, pure golden normalizers, and the Vitest - * suite factory behind `pnpm run test:snapshot`. Because this entry exports `suite.ts`, importing - * it requires a Vitest run. + * ACP snapshot suite kit — the shared machinery behind the keyless snapshot + * tier (`pnpm run test:snapshot`). Four layers, composable per example: the + * shared subprocess/client launcher ({@link launchAcpTestAgent}), the scripted + * scenario harness ({@link runScenario}), the pure golden normalizers + * ({@link normalizeStdout} / {@link normalizeSessionLog} / + * {@link scrubRequestHeaders} / {@link scrubSystemPrompts}), and the suite + * factory ({@link defineAcpSnapshotSuite}) that registers a scenario table as a + * full describe/it tree. Ordinary ACP e2e tests can use the launcher directly; + * an example's `*.snapshot.ts` supplies only its {@link AgentUnderTest} paths, + * snapshots directory, and {@link Scenario} table. + * + * NOTE: ./suite.ts imports vitest, so this package is importable only inside a + * vitest run — a support-tier constraint stated in the README. + * * @module @deepseek-ai/dsh-acp-snapshot */ export { runScenario, - type AgentUnderTest, type HarvestedLog, type InputScript, type InputStep, @@ -15,6 +25,12 @@ export { type RunOptions, type RunResult, } from './harness.ts' +export { + launchAcpTestAgent, + type AcpTestLaunchOptions, + type AgentUnderTest, + type LaunchedAcpTestAgent, +} from './launcher.ts' export { normalizeSessionLog, normalizeStdout, diff --git a/packages/support/acp-snapshot/src/launcher.ts b/packages/support/acp-snapshot/src/launcher.ts new file mode 100644 index 0000000000..de5082eca4 --- /dev/null +++ b/packages/support/acp-snapshot/src/launcher.ts @@ -0,0 +1,276 @@ +/** + * Shared launcher for ACP tests that drive an agent subprocess over JSON-RPC + * stdio. It owns source-or-built launch resolution, workspace environment, + * stdout tee, SDK client, update collection, permission fallback, and process + * shutdown so e2e and snapshot suites do not each reconstruct that boundary. + * + * @module @deepseek-ai/dsh-acp-snapshot/launcher + */ + +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { join } from 'node:path' +import { Readable, Writable } from 'node:stream' +import { + ClientSideConnection, + ndJsonStream, + type Agent as AcpAgent, + type Client, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, +} from '@agentclientprotocol/sdk' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' + +/** The source/built agent entry, leaf config, and workspace tsconfig an ACP test boots. */ +export interface AgentUnderTest { + /** The agent source bin entry (for example `packages/examples/acp-demo/src/bin.ts`). */ + binScript: string + /** Explicit built-mode entry for fixtures whose source path is not under `src/`. */ + libBinScript?: string | undefined + /** The leaf `cordis.yml` loaded by the bin. */ + configPath: string + /** The repo tsconfig whose paths resolve unbuilt workspace imports. */ + tsconfigPath: string +} + +/** Options for one ACP test subprocess. */ +export interface AcpTestLaunchOptions { + /** The agent composition to boot. */ + agent: AgentUnderTest + /** Process cwd and default session-home root. */ + cwd: string + /** Alternate leaf config for this launch. */ + configPath?: string + /** Extra environment values layered over the parent environment. */ + env?: NodeJS.ProcessEnv + /** Permission handler; omitted requests fail closed as `cancelled`. */ + requestPermission?: (params: RequestPermissionRequest) => Promise +} + +/** A running ACP test process and its captured client-side surfaces. */ +export interface LaunchedAcpTestAgent { + /** The child process, exposed for process-level assertions. */ + child: ChildProcessWithoutNullStreams + /** Resolve when the OS spawns the child; reject with its asynchronous spawn failure. */ + spawned: Promise + /** The SDK connection backed by the child's stdio. */ + client: ClientSideConnection + /** Session updates in receive order. */ + updates: SessionNotification['update'][] + /** Decode all stdout bytes captured so far. */ + rawStdout(): string + /** Decode all stderr chunks captured so far. */ + stderr(): string + /** Resolve when a future session update matches the predicate. */ + waitForUpdate(match: (update: SessionNotification['update']) => boolean): Promise + /** Close the process and drain its streams and callbacks; rejects promptly if fallback termination is refused. */ + close(signal?: NodeJS.Signals): Promise +} + +/** + * Boot an ACP agent subprocess and connect an SDK client to its stdio. + * + * @param options Agent paths, cwd, environment, and optional permission handler. + * @returns The running process, connected client, captures, and shutdown handle. + */ +export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTestAgent { + const { agent, cwd } = options + const launch = resolveExampleLaunch({ + srcBin: agent.binScript, + libBin: agent.libBinScript, + configArgs: ['--config', options.configPath ?? agent.configPath], + tsconfigPath: agent.tsconfigPath, + env: { + ...options.env, + DSH_HOME: join(cwd, '.dsh'), + DSH_AGENTS_HOME: join(cwd, '.agents'), + }, + }) + const child = spawn( + launch.command, + launch.args, + { + cwd, + env: { ...process.env, ...launch.env }, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ) + // A spawn-level failure is an asynchronous `error` event. Observe it in the + // same tick as spawn so a missing cwd or OS rejection cannot crash the test + // runner, then make startup and shutdown surface the original error. + // Keep observing after the first error: a fallback kill attempted during + // shutdown may itself report another process error, which must not become an + // unhandled EventEmitter error after the promise has already settled. + const childFailure = new Promise(resolve => child.on('error', resolve)) + const spawned = Promise.race([ + new Promise(resolve => child.once('spawn', resolve)), + childFailure.then((error): never => { throw error }), + ]) + // `spawned` is public and close() also awaits it, but a caller may ignore both. + // Keep that misuse from turning the already-observed child error into an + // unhandled promise rejection. + void spawned.catch(() => undefined) + + const stderrChunks: string[] = [] + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk: string) => stderrChunks.push(chunk)) + + const rawBuffers: Buffer[] = [] + const passthrough = new Readable({ read() {} }) + const updates: SessionNotification['update'][] = [] + const updateWaiters: { + match: (update: SessionNotification['update']) => boolean + resolve: (update: SessionNotification['update']) => void + reject: (reason: unknown) => void + }[] = [] + let updateStreamFailure: Error | undefined + const closeUpdateStream = (): void => { + if (updateStreamFailure !== undefined) return + updateStreamFailure = new Error('ACP test agent update stream closed before a matching session update arrived') + for (const waiter of updateWaiters.splice(0)) waiter.reject(updateStreamFailure) + } + child.stdout.on('data', (buffer: Buffer) => { + rawBuffers.push(buffer) + passthrough.push(buffer) + }) + child.stdout.on('end', () => { + passthrough.push(null) + }) + const stream = ndJsonStream( + Writable.toWeb(child.stdin) as WritableStream, + Readable.toWeb(passthrough) as ReadableStream, + ) + const inFlightClientCallbacks = new Set>() + const trackClientCallback = (callback: () => T | PromiseLike): Promise => { + const pending = Promise.resolve().then(callback) + inFlightClientCallbacks.add(pending) + const untrack = (): void => { inFlightClientCallbacks.delete(pending) } + void pending.then(untrack, untrack) + return pending + } + const requestPermission = options.requestPermission + ?? (() => Promise.resolve({ outcome: { outcome: 'cancelled' as const } })) + const makeClient = (_agent: AcpAgent): Client => ({ + sessionUpdate(params: SessionNotification): Promise { + return trackClientCallback(() => { + updates.push(params.update) + for (let index = updateWaiters.length - 1; index >= 0; index--) { + const waiter = updateWaiters[index] + /* v8 ignore next 1 -- index is bounded by the array length */ + if (waiter === undefined) continue + let matches: boolean + try { + matches = waiter.match(params.update) + } catch (error: unknown) { + updateWaiters.splice(index, 1) + waiter.reject(error) + continue + } + if (!matches) continue + updateWaiters.splice(index, 1) + waiter.resolve(params.update) + } + }) + }, + requestPermission: params => trackClientCallback(() => requestPermission(params)), + }) + const client = new ClientSideConnection(makeClient, stream) + // `exit` only reports the parent process's status. Descendants may retain + // inherited stdout/stderr handles and buffered ACP frames may still be + // crossing the SDK parser. Node's `close` follows stdio closure; the SDK's + // `closed` follows parser exhaustion. Capture both eagerly so a caller that + // invokes close after process exit still joins the complete drain boundary. + const stdioClosed = new Promise(resolve => child.once('close', () => { resolve() })) + const drained = Promise.all([stdioClosed, client.closed]).then(async () => { + // The ACP SDK's readable loop dispatches client callbacks without awaiting + // them. Once `closed` settles no new callbacks can start, but callbacks + // already in flight still belong to this launch's teardown boundary. + while (inFlightClientCallbacks.size > 0) { + await Promise.allSettled([...inFlightClientCallbacks]) + } + }) + // A caller may await a pending update without calling close(). Make natural + // stream exhaustion terminal for those waiters too, but only after the + // parser has dispatched every buffered frame. + void client.closed.then(closeUpdateStream) + + return { + child, + spawned, + client, + updates, + rawStdout: () => Buffer.concat(rawBuffers).toString('utf8'), + stderr: () => stderrChunks.join(''), + waitForUpdate(match): Promise { + if (updateStreamFailure !== undefined) return Promise.reject(updateStreamFailure) + return new Promise((resolve, reject) => updateWaiters.push({ match, resolve, reject })) + }, + async close(signal?: NodeJS.Signals): Promise { + try { + await spawned + } catch (error: unknown) { + await drained + closeUpdateStream() + throw error + } + if (!isRunning(child)) { + await drained + closeUpdateStream() + return + } + const exited = waitForExit(child) + if (signal === undefined) child.stdin.end() + else child.kill(signal) + const failure = await Promise.race([ + exited.then((): undefined => undefined), + childFailure, + ]) + if (failure === undefined) { + await drained + closeUpdateStream() + return + } + + // An `error` after spawn is not an exit edge: in particular, a failed + // signal can leave the subprocess live. Force termination, await the + // already-observed exit edge, and only then propagate the child error so + // callers may safely remove cwd/session resources after close rejects. + const fallbackError = Promise.withResolvers() + const observeFallbackError = (error: Error): void => { fallbackError.resolve(error) } + child.once('error', observeFallbackError) + if (!child.kill('SIGKILL')) { + child.off('error', observeFallbackError) + closeUpdateStream() + throw new AggregateError( + [failure, new Error('Fallback SIGKILL was not accepted by the child process')], + 'ACP test agent failed and fallback termination was refused', + ) + } + const fallbackFailure = await Promise.race([ + exited.then((): undefined => undefined), + fallbackError.promise, + ]) + child.off('error', observeFallbackError) + if (fallbackFailure !== undefined) { + closeUpdateStream() + throw new AggregateError( + [failure, fallbackFailure], + 'ACP test agent failed and fallback termination was refused', + ) + } + await drained + closeUpdateStream() + throw failure + }, + } +} + +/** Resolve once a running child exits. */ +function waitForExit(child: ChildProcessWithoutNullStreams): Promise { + return new Promise(resolve => child.once('exit', () => { resolve() })) +} + +/** Whether the child still lacks either OS termination marker. */ +function isRunning(child: ChildProcessWithoutNullStreams): boolean { + return child.exitCode === null && child.signalCode === null +} diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 322439bb01..938676e4b7 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -15,7 +15,7 @@ * @module @deepseek-ai/dsh-acp-snapshot/suite */ -import { readFile, readdir, writeFile } from 'node:fs/promises' +import { readFile, readdir, rm, writeFile } from 'node:fs/promises' import { existsSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' @@ -71,14 +71,6 @@ export interface Scenario { * false (replay derives from the fixture's `assistant/chunk` events). */ overridden?: boolean - /** - * How many SUBAGENT child sessions this scenario records beyond the top-level - * one (0 for a single-session scenario). Each child rides in a sibling fixture - * `session..jsonl` (1-based); replay forwards them to `dsh-llm-replay` so - * each child session replays from its own script, and record mode writes the - * harvested child logs back to those files. Defaults to 0. - */ - childSessions?: number /** * Whether this scenario is its header class's sole request-header pin. Dedicated sidecars own * the prompt and tool schemas, while every classmate is checked for equality. @@ -129,14 +121,40 @@ export interface SnapshotSuiteOptions { } /** - * The sibling child-fixture paths for a scenario (`session.1.jsonl` …). + * Validate and order a scenario directory's session-fixture filenames. * - * @param dir The scenario's snapshots directory (`/`). - * @param childSessions How many subagent child sessions the scenario records. - * @returns One path per child, 1-based, in fixture order. + * The primary fixture is always `session.jsonl`; child sessions are discovered + * from contiguous `session.1.jsonl` … filenames. The directory is the source of + * truth, so scenario tables do not duplicate a child count that can drift from + * the files. A session-like JSONL with any other suffix fails loud. + * + * @param names File names in one scenario directory. + * @returns The primary and child fixture names in replay/harvest order. */ -export function childFixturePaths(dir: string, childSessions: number): string[] { - return Array.from({ length: childSessions }, (_, i) => join(dir, `session.${i + 1}.jsonl`)) +export function sessionFixtureNames(names: readonly string[]): string[] { + if (!names.includes('session.jsonl')) throw new Error('missing session.jsonl') + const children: { name: string; index: number }[] = [] + for (const name of names) { + if (name === 'session.jsonl') continue + if (!name.startsWith('session.') || !name.endsWith('.jsonl')) continue + const match = /^session\.([1-9]\d*)\.jsonl$/.exec(name) + if (match === null) throw new Error(`invalid child session fixture name: ${name}`) + children.push({ name, index: Number(match[1]) }) + } + children.sort((a, b) => a.index - b.index) + for (const [offset, child] of children.entries()) { + const expected = offset + 1 + if (child.index !== expected) { + throw new Error(`child session fixtures must be contiguous: expected session.${expected}.jsonl, found ${child.name}`) + } + } + return ['session.jsonl', ...children.map(child => child.name)] +} + +/** Read one scenario directory's validated session-fixture inventory. */ +async function sessionFixtures(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + return sessionFixtureNames(entries.filter(entry => entry.isFile()).map(entry => entry.name)) } /** @@ -454,7 +472,12 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { const input = JSON.parse(await readFile(join(dir, 'input.json'), 'utf8')) as InputScript const overrideFile = join(dir, 'replay.override.json') const workspaceDir = join(dir, 'workspace') - const childSessions = scenario.childSessions ?? 0 + // Replay/refresh need the committed inventory up front because those + // files drive the model scripts. Record mode creates that inventory + // from the harvested live logs, so it must also work for a brand-new + // scenario with no session.jsonl yet. + let fixtureFiles = RECORDING ? [] : await sessionFixtures(dir) + const childFixtureFiles = fixtureFiles.slice(1) const comparesLog = scenario.comparesLog ?? scenario.hasModelTurn const result = await runScenario(input, { agent, @@ -463,7 +486,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { ...existsSync(overrideFile) ? { overrideFile } : {}, // In REPLAY, forward the recorded child fixtures so each subagent session // replays from its own script. In RECORD they are harvested, not read. - ...!RECORDING && childSessions > 0 ? { childFiles: childFixturePaths(dir, childSessions) } : {}, + ...!RECORDING && childFixtureFiles.length > 0 ? { childFiles: childFixtureFiles.map(file => join(dir, file)) } : {}, ...existsSync(workspaceDir) ? { workspaceDir } : {}, // A scenario booting an overlay tree passes its own live config; the // bin's replay swap derives the sibling `*cordis.snapshot.yml` from it. @@ -491,7 +514,6 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { const scrub = scenario.pinsHeader === true ? (log: string): string => scrubToolSchemas(scrubSystemPrompts(log)) : scrubRequestHeaders - const fixtureFiles = ['session.jsonl', ...Array.from({ length: childSessions }, (_, i) => `session.${i + 1}.jsonl`)] const existingFixtures = REFRESHING ? await Promise.all(fixtureFiles.map(file => readFile(join(dir, file), 'utf8'))) : [] @@ -500,18 +522,37 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { || (REFRESHING && comparesLog) if (writesSessionFixtures) { expect(result.sessionLogs.length, `${mode} produced no session log to harvest`).toBeGreaterThan(0) - expect(result.sessionLogs.length, `expected ${childSessions + 1} session logs (parent + children)`) - .toBe(childSessions + 1) + if (REFRESHING) { + expect(result.sessionLogs.length, `expected ${fixtureFiles.length} session logs (parent + children)`) + .toBe(fixtureFiles.length) + } + const outputFixtureFiles = [ + 'session.jsonl', + ...Array.from({ length: result.sessionLogs.length - 1 }, (_, i) => `session.${i + 1}.jsonl`), + ] const primary = (result.sessionLogs[0] as HarvestedLog).content - await writeFile(join(dir, 'session.jsonl'), scrub( + await writeFile(join(dir, outputFixtureFiles[0] as string), scrub( REFRESHING ? stabilizeRefreshLog(primary, existingFixtures[0] as string, replacements) : primary, )) for (let i = 1; i < result.sessionLogs.length; i++) { const child = (result.sessionLogs[i] as HarvestedLog).content - await writeFile(join(dir, `session.${i}.jsonl`), scrub( + await writeFile(join(dir, outputFixtureFiles[i] as string), scrub( REFRESHING ? stabilizeRefreshLog(child, existingFixtures[i] as string, replacements) : child, )) } + if (RECORDING) { + const outputNames = new Set(outputFixtureFiles) + const entries = await readdir(dir, { withFileTypes: true }) + await Promise.all(entries + .filter(entry => entry.isFile() + // Only valid numbered children are record-owned stale output. + // Malformed session-like names stay for the inventory guard to + // reject instead of being silently deleted during mutation. + && /^session\.[1-9]\d*\.jsonl$/.test(entry.name) + && !outputNames.has(entry.name)) + .map(entry => rm(join(dir, entry.name)))) + fixtureFiles = outputFixtureFiles + } if (scenario.pinsHeader === true) { const primary = result.sessionLogs[0] as HarvestedLog const prompts = normalizedSystemPrompts(primary.content, ctx) @@ -540,7 +581,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { // produce one without a model turn (a `rejected` turn carrying `hook/*`). if (comparesLog) { // The harvested logs (primary-first) must match their committed fixtures 1:1. - expect(result.sessionLogs.length, 'this scenario must persist a session log').toBe(childSessions + 1) + expect(result.sessionLogs.length, 'this scenario must persist one log per session fixture').toBe(fixtureFiles.length) for (let i = 0; i < fixtureFiles.length; i++) { const harvested = scrub((result.sessionLogs[i] as HarvestedLog).content) const fixture = scrub(await readFile(join(dir, fixtureFiles[i] as string), 'utf8')) @@ -619,9 +660,9 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { expect(onDisk).toEqual(registered) }) - it('every registered scenario has its required fixture files', () => { - // Every scenario has an input script and an stdout golden. - for (const { name, overridden, childSessions, pinsHeader } of scenarios) { + it('every registered scenario has its required fixture files', async () => { + // Every scenario needs input, stdout, a primary session fixture, and matching optional sidecars. + for (const { name, overridden, pinsHeader } of scenarios) { const dir = join(snapshotsDir, name) expect(existsSync(join(dir, 'input.json')), `${name}/input.json`).toBe(true) expect(existsSync(join(dir, 'stdout.golden.jsonl')), `${name}/stdout.golden.jsonl`).toBe(true) @@ -632,11 +673,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { .toBe(pinsHeader === true) expect(existsSync(join(dir, TOOL_SCHEMAS_SNAPSHOT)), `${name}/${TOOL_SCHEMAS_SNAPSHOT} presence must match \`pinsHeader\``) .toBe(pinsHeader === true) - // A nested-agent scenario ships one child fixture per recorded subagent - // session (`session.1.jsonl` …), the replay source for that child session. - for (const childFixture of childFixturePaths(dir, childSessions ?? 0)) { - expect(existsSync(childFixture), childFixture).toBe(true) - } + await expect(sessionFixtures(dir), `${name}: session fixture inventory`).resolves.toBeDefined() } }) @@ -688,10 +725,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { // storage rules fail loud. for (const scenario of scenarios) { const dir = join(snapshotsDir, scenario.name) - const files = [ - 'session.jsonl', - ...Array.from({ length: scenario.childSessions ?? 0 }, (_, i) => `session.${i + 1}.jsonl`), - ] + const files = await sessionFixtures(dir) for (const file of files) { const fixture = await readFile(join(dir, file), 'utf8') expect(unknownToolCallIds(fixture), `${scenario.name}/${file} contains UNKNOWN_TOOL`) diff --git a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts index fccd1d6fcc..5dd5524ed0 100644 --- a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts +++ b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts @@ -6,6 +6,7 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { readdirSync } from 'node:fs' +import { spawn } from 'node:child_process' import { dirname, join } from 'node:path' import { randomUUID } from 'node:crypto' import { createInterface } from 'node:readline' @@ -40,6 +41,8 @@ interface Behavior { echoWorkspace?: boolean /** Write a line to stderr on boot (spec-side stderr-capture assertions). */ stderrNote?: string + /** Let a short-lived descendant retain stdio and emit one final ACP update plus stderr line after this parent exits. */ + lateInheritedOutput?: boolean /** Session logs to persist on stdin EOF. */ logs?: ScriptedLog[] /** Leave a stray FILE directly under the sessions root (harvest must skip it). */ @@ -247,6 +250,24 @@ function flushLogsAndExit(): void { writeFileSync(join(sessionsRoot, 'bucket-noise', 'notes.txt'), 'not a session log\n') } if (behavior.deleteSessionsRoot === true) rmSync(sessionsRoot, { recursive: true, force: true }) + if (behavior.lateInheritedOutput === true) { + const frame = JSON.stringify({ + jsonrpc: '2.0', + method: 'session/update', + params: { + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'late inherited stdout' }, + }, + }, + }) + const code = [ + `setTimeout(() => process.stdout.write(${JSON.stringify(`${frame}\n`)}), 50)`, + `setTimeout(() => process.stderr.write(${JSON.stringify('late inherited stderr\n')}), 75)`, + ].join(';') + spawn(process.execPath, ['-e', code], { stdio: ['ignore', 1, 2] }).unref() + } process.exit(0) } diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 1d953f5e95..2b9d6e032f 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -1,13 +1,34 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { once } from 'node:events' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { afterAll, describe, expect, it } from 'vitest' +import { afterAll, describe, expect, it, vi } from 'vitest' +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { runScenario, type AgentUnderTest, type InputStep } from '../src/harness.ts' +import { launchAcpTestAgent } from '../src/launcher.ts' + +const fsControl = vi.hoisted(() => ({ cleanupFailure: undefined as Error | undefined })) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async rm(...args: Parameters): Promise { + if (String(args[0]).includes('acp-snap-cwd-') && fsControl.cleanupFailure !== undefined) { + const failure = fsControl.cleanupFailure + fsControl.cleanupFailure = undefined + await actual.rm(...args) + throw failure + } + await actual.rm(...args) + }, + } +}) /** * Unit tests for the subprocess harness, driven through the REAL spawn path - * (tsx loader, temp cwd, env plumbing) against the scripted fake ACP bin in + * (mode-aware launcher, temp cwd, env plumbing) against the scripted fake ACP bin in * ./fixtures/fake-acp-agent.ts. Each case writes a `behavior.json` next to a * throwaway fixture path; the fake bin echoes observable facts (env, seeded * workspace, permission outcomes) into `agent_message_chunk` text, so the @@ -40,6 +61,181 @@ async function scenario(behavior: object): Promise<{ dir: string; fixtureFile: s const boot: InputStep[] = [{ op: 'initialize' }, { op: 'newSession' }] describe('runScenario', () => { + it('surfaces an asynchronous child spawn failure through startup and close', async () => { + const { dir } = await scenario({}) + const launched = launchAcpTestAgent({ agent: AGENT, cwd: join(dir, 'missing') }) + let stdioClosed = false + let clientClosed = false + launched.child.once('close', () => { stdioClosed = true }) + void launched.client.closed.then( + () => { clientClosed = true }, + () => { clientClosed = true }, + ) + await expect(launched.spawned).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(launched.close()).rejects.toMatchObject({ code: 'ENOENT' }) + expect(stdioClosed).toBe(true) + expect(clientClosed).toBe(true) + }) + + it('centralizes ACP boot, captures, updates, fail-closed permissions, and shutdown', { timeout: 20_000 }, async () => { + const { dir, fixtureFile } = await scenario({ permissionProbe: true, echoEnv: true, stderrNote: 'launcher stderr' }) + const sessionsRoot = await mkdtemp(join(tmpdir(), 'acp-launcher-sessions-')) + tempDirs.push(sessionsRoot) + const launched = launchAcpTestAgent({ + agent: AGENT, + cwd: dir, + configPath: AGENT.configPath, + env: { + DSH_SNAPSHOT: 'replay', + DSH_SNAPSHOT_FILE: fixtureFile, + DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot, + }, + }) + await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await launched.client.newSession({ cwd: dir, mcpServers: [] }) + const nextChunk = launched.waitForUpdate(update => update.sessionUpdate === 'agent_message_chunk') + const predicateFailure = new Error('predicate failed') + const failedPredicate = launched.waitForUpdate(() => { throw predicateFailure }) + .catch((error: unknown): unknown => error) + await launched.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) + expect(await failedPredicate).toBe(predicateFailure) + expect((await nextChunk).sessionUpdate).toBe('agent_message_chunk') + expect(launched.updates.some(update => update.sessionUpdate === 'agent_message_chunk')).toBe(true) + expect(launched.rawStdout()).toContain('permission:{\\"outcome\\":\\"cancelled\\"}') + expect(launched.stderr()).toContain('launcher stderr') + const unmatched = expect(launched.waitForUpdate(() => false)).rejects.toThrow(/update stream closed/) + await launched.close() + await unmatched + await expect(launched.waitForUpdate(() => true)).rejects.toThrow(/update stream closed/) + await launched.close('SIGKILL') + + // The minimal shape needs no environment or config override. + const minimal = launchAcpTestAgent({ agent: AGENT, cwd: dir }) + await minimal.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const childFailure = new Error('child process failed') + let exited = false + minimal.child.once('exit', () => { exited = true }) + minimal.child.emit('error', childFailure) + await expect(minimal.close('SIGTERM')).rejects.toBe(childFailure) + // close rejects only after the fallback SIGKILL has produced an exit edge. + expect(exited).toBe(true) + }) + + it('waits for inherited stdio and buffered ACP parsing after the parent exits', { timeout: 20_000 }, async () => { + const { dir, fixtureFile } = await scenario({ lateInheritedOutput: true }) + const launched = launchAcpTestAgent({ + agent: AGENT, + cwd: dir, + env: { DSH_SNAPSHOT_FILE: fixtureFile }, + }) + await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + await launched.client.newSession({ cwd: dir, mcpServers: [] }) + const lateUpdate = launched.waitForUpdate(update => + update.sessionUpdate === 'agent_message_chunk' + && update.content.type === 'text' + && update.content.text === 'late inherited stdout') + + await launched.close() + + await expect(lateUpdate).resolves.toMatchObject({ sessionUpdate: 'agent_message_chunk' }) + expect(launched.rawStdout()).toContain('late inherited stdout') + expect(launched.stderr()).toContain('late inherited stderr') + }) + + it('rejects promptly when fallback termination is refused', async () => { + const { dir } = await scenario({}) + const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir }) + await launched.spawned + + const childFailure = Object.assign(new Error('signal refused'), { code: 'EPERM' }) + const originalKill = launched.child.kill.bind(launched.child) + const kill = vi.spyOn(launched.child, 'kill').mockReturnValue(false) + const closed = new Promise(resolve => launched.child.once('close', () => { resolve() })) + try { + launched.child.emit('error', childFailure) + const rejection = await launched.close('SIGTERM').catch((error: unknown): unknown => error) + expect(rejection).toBeInstanceOf(AggregateError) + expect(rejection).toMatchObject({ + message: 'ACP test agent failed and fallback termination was refused', + errors: [ + childFailure, + expect.objectContaining({ message: 'Fallback SIGKILL was not accepted by the child process' }), + ], + }) + expect(kill).toHaveBeenNthCalledWith(1, 'SIGTERM') + expect(kill).toHaveBeenNthCalledWith(2, 'SIGKILL') + } finally { + kill.mockRestore() + originalKill('SIGKILL') + await closed + } + }) + + it('rejects promptly when fallback termination emits an error', async () => { + const { dir } = await scenario({}) + const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir }) + await launched.spawned + + const childFailure = Object.assign(new Error('signal refused'), { code: 'EPERM' }) + const fallbackFailure = Object.assign(new Error('fallback signal refused'), { code: 'EPERM' }) + const originalKill = launched.child.kill.bind(launched.child) + const kill = vi.spyOn(launched.child, 'kill').mockImplementation((signal) => { + if (signal === 'SIGKILL') queueMicrotask(() => launched.child.emit('error', fallbackFailure)) + return signal === 'SIGKILL' + }) + const closed = new Promise(resolve => launched.child.once('close', () => { resolve() })) + try { + launched.child.emit('error', childFailure) + const rejection = await launched.close('SIGTERM').catch((error: unknown): unknown => error) + expect(rejection).toBeInstanceOf(AggregateError) + expect(rejection).toMatchObject({ + message: 'ACP test agent failed and fallback termination was refused', + errors: [childFailure, fallbackFailure], + }) + expect(kill).toHaveBeenNthCalledWith(1, 'SIGTERM') + expect(kill).toHaveBeenNthCalledWith(2, 'SIGKILL') + } finally { + kill.mockRestore() + originalKill('SIGKILL') + await closed + } + }) + + it('waits for in-flight client callbacks after the ACP stream closes', { timeout: 20_000 }, async () => { + const { dir, fixtureFile } = await scenario({ permissionProbe: true }) + let releasePermission: (() => void) | undefined + const permissionReleased = new Promise((resolve) => { releasePermission = resolve }) + let markPermissionStarted: (() => void) | undefined + const permissionStarted = new Promise((resolve) => { markPermissionStarted = resolve }) + let permissionFinished = false + const launched = launchAcpTestAgent({ + agent: AGENT, + cwd: dir, + env: { DSH_SNAPSHOT_FILE: fixtureFile }, + async requestPermission() { + markPermissionStarted?.() + await permissionReleased + permissionFinished = true + return { outcome: { outcome: 'cancelled' } } + }, + }) + await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await launched.client.newSession({ cwd: dir, mcpServers: [] }) + void launched.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => undefined) + await permissionStarted + + const childClosed = once(launched.child, 'close') + let closeSettled = false + const closing = launched.close('SIGKILL').then(() => { closeSettled = true }) + await childClosed + await launched.client.closed + expect(closeSettled).toBe(false) + + releasePermission?.() + await closing + expect(permissionFinished).toBe(true) + }) + it('includes agent stderr when the ACP connection closes during startup', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ failOnBoot: true, stderrNote: 'fake agent requested startup failure' }) await expect(runScenario( @@ -48,6 +244,23 @@ describe('runScenario', () => { )).rejects.toThrow(/agent stderr:\nfake agent requested startup failure/) }) + it('preserves launch-resolution errors when no child process exists', async () => { + const { dir, fixtureFile } = await scenario({}) + vi.stubEnv('DSH_EXAMPLE_MODE', 'lib') + try { + await expect(runScenario( + { steps: [] }, + { + agent: { ...AGENT, binScript: join(dir, 'outside-src.ts'), libBinScript: undefined }, + mode: 'replay', + fixtureFile, + }, + )).rejects.toThrow(/expected a "\/src\/" segment/) + } finally { + vi.unstubAllEnvs() + } + }) + it('drives a full turn: initialize (terminal caps), session, prompt, permission stub, harvest', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ permissionProbe: true, @@ -138,6 +351,39 @@ describe('runScenario', () => { )).rejects.toThrow(/expected the prompt to fail/) }) + it('reports scenario and cleanup failures together', { timeout: 20_000 }, async () => { + const { fixtureFile } = await scenario({ prompt: 'respond' }) + const cleanupFailure = new Error('cleanup failed') + fsControl.cleanupFailure = cleanupFailure + + const failure = await runScenario( + { steps: [...boot, { op: 'promptExpectError', text: 'fine' }] }, + { agent: AGENT, mode: 'replay', fixtureFile }, + ).catch((error: unknown): unknown => error) + + expect(failure).toBeInstanceOf(AggregateError) + const failures = (failure as AggregateError).errors as unknown[] + expect(failures).toHaveLength(2) + expect(failures[0]).toBeInstanceOf(Error) + expect((failures[0] as Error).message).toMatch(/expected the prompt to fail/) + expect(failures[1]).toBe(cleanupFailure) + }) + + it('reports cleanup failure after an otherwise successful scenario', { timeout: 20_000 }, async () => { + const { fixtureFile } = await scenario({}) + const cleanupFailure = new Error('cleanup failed') + fsControl.cleanupFailure = cleanupFailure + + const failure = await runScenario( + { steps: boot }, + { agent: AGENT, mode: 'replay', fixtureFile }, + ).catch((error: unknown): unknown => error) + + expect(failure).toBeInstanceOf(AggregateError) + expect((failure as AggregateError).message).toBe('snapshot cleanup failed') + expect((failure as AggregateError).errors as unknown[]).toEqual([cleanupFailure]) + }) + it('newSessionExpectError swallows the rejection, with and without extra dirs', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ rejectExtraDirs: true }) const result = await runScenario( diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index 4cedc1cdfb..92e676b1d6 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -1,4 +1,4 @@ -import { cpSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -6,7 +6,6 @@ import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it } from 'vitest' import { defineAcpSnapshotSuite, type HarvestedLog, type Scenario } from '../src/index.ts' import { - childFixturePaths, fixtureContext, formatSystemPromptSnapshot, headerChangeCount, @@ -16,6 +15,7 @@ import { normalizedToolSchemas, parseToolSchemasSnapshot, refreshFixtureReplacements, + sessionFixtureNames, restorePinnedToolSchemas, stabilizeRefreshLog, unknownToolCallIds, @@ -46,7 +46,7 @@ const RECORD_SRC = fileURLToPath(new URL('./fixtures/record-suite', import.meta. // Replay pins explicit header classes; recording covers the default fallback. const REPLAY_SCENARIOS: Scenario[] = [ { name: 'pin-turn', hasModelTurn: true, recorded: true, pinsHeader: true, expectedHeaderChanges: 1, headerClass: 'main' }, - { name: 'plain-turn', hasModelTurn: true, recorded: true, childSessions: 1, headerClass: 'main', configPath: AGENT.configPath }, + { name: 'plain-turn', hasModelTurn: true, recorded: true, headerClass: 'main', configPath: AGENT.configPath }, { name: 'no-model', hasModelTurn: false, recorded: false, headerClass: 'main' }, { name: 'blocked-log', hasModelTurn: false, comparesLog: true, recorded: false, headerClass: 'main' }, { name: 'authored-error', hasModelTurn: true, recorded: false, overridden: true, headerClass: 'main' }, @@ -54,7 +54,7 @@ const REPLAY_SCENARIOS: Scenario[] = [ const RECORD_SCENARIOS: Scenario[] = [ { name: 'rec-pin', hasModelTurn: true, recorded: true, pinsHeader: true }, - { name: 'rec-child', hasModelTurn: true, recorded: true, childSessions: 1 }, + { name: 'rec-child', hasModelTurn: true, recorded: true }, // recorded:false in record mode → registered but skipped (never re-recorded). { name: 'rec-skip', hasModelTurn: true, recorded: false, overridden: true }, ] @@ -64,7 +64,13 @@ const RECORD_SCENARIOS: Scenario[] = [ // committed record fixtures/goldens in place. const BOOTSTRAP = process.env.ACP_SNAPSHOT_SPEC_BOOTSTRAP === '1' const recordDir = BOOTSTRAP ? RECORD_SRC : mkdtempSync(join(tmpdir(), 'acp-snap-record-suite-')) -if (!BOOTSTRAP) cpSync(RECORD_SRC, recordDir, { recursive: true }) +if (!BOOTSTRAP) { + cpSync(RECORD_SRC, recordDir, { recursive: true }) + // Record mode owns its output inventory: a new scenario has no primary yet, + // while a changed child count can leave old numbered fixtures behind. + rmSync(join(recordDir, 'rec-pin', 'session.jsonl')) + writeFileSync(join(recordDir, 'rec-child', 'session.2.jsonl'), 'stale child\n') +} const refreshDir = mkdtempSync(join(tmpdir(), 'acp-snap-refresh-suite-')) cpSync(REPLAY_DIR, refreshDir, { recursive: true }) staleRefreshFixtures(refreshDir) @@ -140,6 +146,13 @@ describe('defineAcpSnapshotSuite: refresh write-back', () => { }) }) +describe('defineAcpSnapshotSuite: record inventory write-back', () => { + it('creates a missing primary fixture and prunes stale child fixtures', () => { + expect(readFileSync(join(recordDir, 'rec-pin', 'session.jsonl'), 'utf8')).toContain('"type":"session"') + expect(() => readFileSync(join(recordDir, 'rec-child', 'session.2.jsonl'), 'utf8')).toThrow() + }) +}) + describe('defineAcpSnapshotSuite: registration contract', () => { it("throws when a scenario's header class has no pinning scenario", () => { expect(() => { @@ -179,13 +192,41 @@ describe('defineAcpSnapshotSuite: registration contract', () => { }) }) -describe('childFixturePaths', () => { - it('yields one sibling path per child, 1-based', () => { - expect(childFixturePaths('/snap/s', 2)).toEqual(['/snap/s/session.1.jsonl', '/snap/s/session.2.jsonl']) +describe('sessionFixtureNames', () => { + it('orders the primary and contiguous child fixtures while ignoring other files', () => { + expect(sessionFixtureNames([ + 'stdout.golden.jsonl', + 'session.2.jsonl', + 'session.jsonl', + 'session.1.jsonl', + 'input.json', + ])).toEqual(['session.jsonl', 'session.1.jsonl', 'session.2.jsonl']) }) - it('yields nothing for a single-session scenario', () => { - expect(childFixturePaths('/snap/s', 0)).toEqual([]) + it('accepts a primary-only scenario', () => { + expect(sessionFixtureNames(['session.jsonl'])).toEqual(['session.jsonl']) + }) + + it('rejects a directory without the primary fixture', () => { + expect(() => sessionFixtureNames(['session.1.jsonl'])).toThrow('missing session.jsonl') + }) + + it('rejects gapped child fixtures', () => { + expect(() => sessionFixtureNames(['session.jsonl', 'session.2.jsonl'])) + .toThrow('expected session.1.jsonl, found session.2.jsonl') + }) + + it.each(['session.0.jsonl', 'session.child.jsonl', 'session.01.jsonl'])( + 'rejects invalid child fixture name %s', + (name) => { + expect(() => sessionFixtureNames(['session.jsonl', name])) + .toThrow(`invalid child session fixture name: ${name}`) + }, + ) + + it('rejects duplicate child indexes', () => { + expect(() => sessionFixtureNames(['session.jsonl', 'session.1.jsonl', 'session.1.jsonl'])) + .toThrow('expected session.2.jsonl, found session.1.jsonl') }) }) diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index 51e799114f..3299905a07 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -20,7 +20,7 @@ import { LlmAdapter, LlmError, assertNever } from '@deepseek-ai/dsh-llm' */ export type ReplayEntry = | { kind: 'chunks'; chunks: StreamChunk[] } - | { kind: 'throw'; chunks: StreamChunk[]; message: string; code: string; status?: number } + | { kind: 'throw'; chunks: StreamChunk[]; message: string; code: string } | { kind: 'hang' } /** One model exposed by a replay-only provider catalog. */ @@ -283,7 +283,7 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined) if (signal?.aborted) throw new Error('aborted') yield chunk } - throw new LlmError(entry.message, entry.code, entry.status) + throw new LlmError(entry.message, entry.code) case 'hang': // Replay a stream that stalls until cancelled (mirrors MockAdapter): one // chunk, then wait for abort and surface it as the consumer expects. diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index c9bd545b36..59ffc485db 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -175,7 +175,7 @@ describe('loadReplayScript', () => { it('uses the sidecar override when present, ignoring the JSONL', () => { writeFileSync(file, sessionJsonl([]), 'utf8') const overrideFile = join(dir, 'replay.override.json') - const override: ReplayEntry[] = [{ kind: 'throw', chunks: [], message: '401', code: 'AUTH', status: 401 }] + const override: ReplayEntry[] = [{ kind: 'throw', chunks: [], message: '401', code: 'AUTH' }] writeFileSync(overrideFile, JSON.stringify(override), 'utf8') expect(loadReplayScript({ file, overrideFile })).toEqual(override) }) @@ -265,12 +265,12 @@ describe('installLlmReplay (through the real LlmService)', () => { expect(await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [] }))).toEqual(second) }) - it('replays a sidecar throw-entry as an LlmError with code/status, after its prefix chunks', async () => { + it('replays a sidecar throw-entry as an LlmError with its stable code, after its prefix chunks', async () => { writeFileSync(file, sessionJsonl([]), 'utf8') const overrideFile = join(dir, 'replay.override.json') const partial: StreamChunk[] = [{ type: 'block-start', index: 0, blockType: 'text' }] writeFileSync(overrideFile, JSON.stringify([ - { kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH', status: 401 }, + { kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH' }, ]), 'utf8') const ctx = new Context() await ctx.plugin(LlmService) @@ -279,7 +279,7 @@ describe('installLlmReplay (through the real LlmService)', () => { const seen: StreamChunk[] = [] await expect((async () => { for await (const c of ctx.llm.stream({ provider: 'm', model: 'm', messages: [] })) seen.push(c) - })()).rejects.toMatchObject({ message: 'unauthorized', code: 'AUTH', status: 401 }) + })()).rejects.toMatchObject({ message: 'unauthorized', code: 'AUTH' }) expect(seen).toEqual(partial) }) @@ -384,7 +384,7 @@ describe('installLlmReplay (through the real LlmService)', () => { const overrideFile = join(dir, 'replay.override.json') const partial: StreamChunk[] = [{ type: 'block-start', index: 0, blockType: 'text' }] writeFileSync(overrideFile, JSON.stringify([ - { kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH', status: 401 }, + { kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH' }, ]), 'utf8') const ctx = new Context() await ctx.plugin(LlmService) diff --git a/packages/support/subagent-mock/package.json b/packages/support/subagent-mock/package.json index a4ed0a0c6e..c9d0982f55 100644 --- a/packages/support/subagent-mock/package.json +++ b/packages/support/subagent-mock/package.json @@ -24,6 +24,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -33,6 +34,7 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@cordisjs/plugin-loader": "^1.0.0-rc.5", "cordis": "^4.0.0-rc.7" diff --git a/packages/support/subagent-mock/src/index.ts b/packages/support/subagent-mock/src/index.ts index 5032c92529..4ea30d166a 100644 --- a/packages/support/subagent-mock/src/index.ts +++ b/packages/support/subagent-mock/src/index.ts @@ -7,8 +7,8 @@ import type { Context } from 'cordis' import z from 'schemastery' -import { AgentId } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' import type { SubagentCapabilities, SubagentProvider, @@ -54,7 +54,7 @@ class MockSubagentProvider implements SubagentProvider { // A deterministic child id derived from the parent — no clock/random (both // banned in deterministic paths here, and unnecessary for a scripted run). - const id = AgentId(`mock-subagent:${this.name}:${request.parent.id}`) + const id = SessionId(`mock-subagent:${this.name}:${request.parent.id}`) const resultFor = (): SubagentResult => ({ output, @@ -69,6 +69,7 @@ class MockSubagentProvider implements SubagentProvider { }) return { id, + localAgent: undefined, result, dispose(): Promise { flags.cancelled = true diff --git a/packages/support/subagent-mock/tests/subagent-mock.spec.ts b/packages/support/subagent-mock/tests/subagent-mock.spec.ts index c9700a14b1..aec0fd7db1 100644 --- a/packages/support/subagent-mock/tests/subagent-mock.spec.ts +++ b/packages/support/subagent-mock/tests/subagent-mock.spec.ts @@ -1,13 +1,15 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import { type Agent } from '@deepseek-ai/dsh-agent' + import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import * as mock from '../src/index.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** A minimal parent — the mock provider only reads `parent.id`. */ function fakeParent(id = 'parent-1'): Agent { - return { id: AgentId(id) } as unknown as Agent + return { id: SessionId(id) } as unknown as Agent } function baseRequest(over: Partial = {}): SubagentStartRequest { diff --git a/packages/tasks/tasks/src/index.ts b/packages/tasks/tasks/src/index.ts index e9c896c3a1..457f5473a3 100644 --- a/packages/tasks/tasks/src/index.ts +++ b/packages/tasks/tasks/src/index.ts @@ -151,9 +151,9 @@ export class TaskService extends Service { * @returns fresh snapshots. */ list(caller?: Agent): TaskSnapshot[] { - const session = caller?.session.header.id + const session = caller?.id return [...this.store.values()] - .filter(task => task.owner === undefined || task.owner.session.header.id === session) + .filter(task => task.owner === undefined || task.owner.id === session) .map(task => this.snapshot(task)) } @@ -317,14 +317,14 @@ export class TaskService extends Service { * open, and a no-agent caller can never match an owned one). */ private assertAccess(task: TrackedTask, caller?: Agent): void { - if (task.owner !== undefined && task.owner.session.header.id !== caller?.session.header.id) { + if (task.owner !== undefined && task.owner.id !== caller?.id) { throw new Error(`task ${task.id} belongs to another session`) } } /** Project a fresh read-only snapshot from the mutable record. */ private snapshot(task: TrackedTask): TaskSnapshot { - const ownerSession = task.owner?.session.header.id + const ownerSession = task.owner?.id return { id: task.id, kind: task.kind, diff --git a/packages/tasks/tasks/tests/tasks.spec.ts b/packages/tasks/tasks/tests/tasks.spec.ts index f21eb9c433..0d3eae8338 100644 --- a/packages/tasks/tasks/tests/tasks.spec.ts +++ b/packages/tasks/tasks/tests/tasks.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import TaskService, { TaskId } from '@deepseek-ai/dsh-tasks' import type { TaskHooks, TaskKind, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks' @@ -14,13 +14,13 @@ declare module '@deepseek-ai/dsh-tasks' { const agentScopeDisposers = new WeakMap Promise>() -function stubAgent(ctx: Context, rawId: string, rawSessionId = `${rawId}-session`): Agent { - const id = AgentId(rawId) +function stubAgent(ctx: Context, rawId: string): Agent { + const id = SessionId(rawId) const scopeFiber = ctx.plugin(() => {}) const agent = { id, options: {}, - session: new Session(SessionId(rawSessionId)), + session: new Session(id), status: 'idle' as const, ctx: scopeFiber.ctx, send() {}, @@ -453,11 +453,11 @@ describe('TaskService owner isolation', () => { it('rejects a stale owner instance after another agent reuses its id', async () => { const ctx = await harness() - const staleOwner = stubAgent(ctx, 'owner', 'stale-session') + const staleOwner = stubAgent(ctx, 'owner') const unregisterStale = ctx.agents.register(staleOwner) unregisterStale() - const currentOwner = stubAgent(ctx, 'owner', 'current-session') + const currentOwner = stubAgent(ctx, 'owner') ctx.agents.register(currentOwner) const current = producer({ owner: currentOwner }) ctx.tasks.start(current.spec) // Attach the current owner's cleanup first. @@ -467,7 +467,10 @@ describe('TaskService owner isolation', () => { expect(() => ctx.tasks.start({ ...stale.spec, run: staleRun })) .toThrow('is not the registered agent instance') expect(staleRun).not.toHaveBeenCalled() - expect(ctx.tasks.list(staleOwner)).toEqual([]) + // Access is keyed by the unified session id, so a reconnect carrying the + // same identity can observe the current task even though stale ownership + // registration is rejected by exact-instance validation. + expect(ctx.tasks.list(staleOwner)).toHaveLength(1) expect(ctx.tasks.list(currentOwner)).toHaveLength(1) current.settle({ status: 'completed' }) @@ -524,7 +527,7 @@ describe('TaskService owner cleanup', () => { it('does not let an old scope cleanup cancel a same-id/session replacement task', async () => { const ctx = await harness() - const oldOwner = stubAgent(ctx, 'owner', 'shared-session') + const oldOwner = stubAgent(ctx, 'owner') const detachOld = ctx.agents.register(oldOwner) const cancels: string[] = [] @@ -543,7 +546,7 @@ describe('TaskService owner cleanup', () => { start(oldOwner, 'old task') detachOld() - const replacement = stubAgent(ctx, 'owner', 'shared-session') + const replacement = stubAgent(ctx, 'owner') ctx.agents.register(replacement) const replacementId = start(replacement, 'replacement task') diff --git a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts index 1b5e221a3c..0a2d89431e 100644 --- a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts +++ b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts @@ -5,6 +5,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' import TaskService from '@deepseek-ai/dsh-tasks' import type { TaskHooks, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' @@ -23,17 +24,17 @@ async function setup(config: ToolTasks.Config = {}) { } /** - * A fake agent whose session token is `sessionId`, registered in `ctx.agents`. - * The agent id is deliberately different so session authorization and exact - * lifecycle ownership cannot be confused in tests. + * A fake agent with the shared agent/session identity, registered in + * `ctx.agents` with a dedicated lifecycle scope. */ function fakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Agent { const scopeFiber = ctx.plugin(() => {}) + const id = SessionId(sessionId) const agent = { - id: `agent-${sessionId}`, + id, ctx: scopeFiber.ctx, inject, - session: { header: { version: 0, id: sessionId, createdAt: 0 } }, + session: { id, header: { version: 0, id, createdAt: 0 } }, } as unknown as Agent agentRegistryDisposers.set(agent, ctx.agents.register(agent)) return agent @@ -276,7 +277,7 @@ describe('completion notices', () => { await tick() // Disposed owner: inject throws the disposed message — contained. - const inject = vi.fn(() => { throw new Error('agent "agent-sess-1" is disposed') }) + const inject = vi.fn(() => { throw new Error('agent "sess-1" is disposed') }) const owner = fakeAgent(ctx, 'sess-1', inject) const p = producer({ owner }) ctx.tasks.start(p.spec) @@ -287,7 +288,7 @@ describe('completion notices', () => { it('does not route an old owner completion notice to a same-session replacement', async () => { const { ctx } = await setup() - const oldInject = vi.fn(() => { throw new Error('agent "agent-shared" is disposed') }) + const oldInject = vi.fn(() => { throw new Error('agent "shared" is disposed') }) const oldOwner = fakeAgent(ctx, 'shared', oldInject) const p = producer({ owner: oldOwner }) ctx.tasks.start(p.spec) diff --git a/packages/todo/tool-todo/tests/integration.spec.ts b/packages/todo/tool-todo/tests/integration.spec.ts index 97c24a822e..869c1183f2 100644 --- a/packages/todo/tool-todo/tests/integration.spec.ts +++ b/packages/todo/tool-todo/tests/integration.spec.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import type { SessionEvent } from '@deepseek-ai/dsh-session' -import { AgentId } from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -22,7 +22,7 @@ async function harness(adapter: MockAdapter): Promise { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -57,7 +57,7 @@ describe('todo_write tool through the agent loop', () => { textResponse('Plan recorded.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('it-todo'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('it-todo'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'plan a two-step task' }]) await waitForIdle(ctx, agent) @@ -85,7 +85,7 @@ describe('todo_write tool through the agent loop', () => { textResponse('Done planning.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('it-todo-2'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('it-todo-2'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'plan then update' }]) await waitForIdle(ctx, agent) diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts index c2843cbcfe..2059bf13e8 100644 --- a/packages/todo/tool-todo/tests/tool-todo.spec.ts +++ b/packages/todo/tool-todo/tests/tool-todo.spec.ts @@ -6,7 +6,8 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import { Session, SessionId } from '@deepseek-ai/dsh-session' import type { TodoItem } from '@deepseek-ai/dsh-session' -import { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import { type Agent } from '@deepseek-ai/dsh-agent' + import * as tool from '../src/index.ts' /** @@ -20,7 +21,7 @@ import * as tool from '../src/index.ts' /** A parent Agent backed by a real Session — the tool reads `agent.session`. */ function agentWithSession(id = 'parent-1'): Agent & { session: Session } { const session = new Session(SessionId(id)) - return { id: AgentId(id), session } as unknown as Agent & { session: Session } + return { id: SessionId(id), session } as unknown as Agent & { session: Session } } async function setup(): Promise { diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index 300cd69023..403c91acd8 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -8,7 +8,7 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline ` `apply(ctx, config)` — wires an `AgentSideConnection` (from `@agentclientprotocol/sdk`) to `process.stdin`/`process.stdout` and implements the ACP `Agent` method surface. -The plugin injects `agents`, `sessions`, `sessionPersistence`, `tools`, `userInteraction`, `llm`, and `systemPrompt`, never the concrete loop. Persistence backs `session/load`; the LLM catalog backs model selection; prompt assembly keeps model variables aligned with routing; tool definitions own presentation; user interaction maps agent questions to ACP forms. +The plugin injects `agents`, `sessionPersistence`, `tools`, `userInteraction`, `llm`, and `systemPrompt`, never the concrete loop. Persistence backs `session/load`; the LLM catalog backs model selection; prompt assembly keeps model variables aligned with routing; tool definitions own presentation; user interaction maps agent questions to ACP forms. ### Config @@ -37,7 +37,7 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name: ## Multi-session -Forward and reverse indexes route every event, prompt, cancel, and approval to one session. Each session permits one in-flight prompt; teardown drains all sessions in parallel. See the [multi-session RFC](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md). +One id-keyed record map plus exact agent-object checks route every event, prompt, cancel, and approval to one session. Each session permits one in-flight prompt; teardown drains all sessions in parallel. See the [multi-session RFC](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md). ## Session config options diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index e8fb326d9b..e20cd40f10 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -1,8 +1,8 @@ /** - * Multi-session ACP server bridge over JSON-RPC stdio. Creates or resumes - * agents, routes their events, settles prompts by turn, and answers approvals. - * Each session keeps independent presentation and prompt-correlation state so - * concurrent streams cannot cross. Stdout is reserved for protocol frames. + * Multi-session ACP bridge over JSON-RPC stdio. Creates or resumes agents, + * routes session-scoped events and approvals, and settles prompts by turn. + * Stdout is reserved for protocol frames. + * * @module @deepseek-ai/dsh-acp */ @@ -45,7 +45,6 @@ import { import type { ContentBlock, LlmCallConfig, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' import { assertNever, CallId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' -import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' // Side-effect type import: resolves `ctx.get('permission')` to the service. import type {} from '@deepseek-ai/dsh-permission' @@ -76,16 +75,15 @@ import { } from './codec.ts' export const name = 'acp' -// Interface services back advertised loading, tool-owned presentation with a generic fallback, and interaction. -// TODO(acp-session-inject): remove `sessions`; the bridge never reads it, and ownership is already behind `agents`. -export const inject = ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction', 'llm', 'systemPrompt'] +// Interface services back loading, presentation, interaction, and prompt assembly. +export const inject = ['agents', 'sessionPersistence', 'tools', 'userInteraction', 'llm', 'systemPrompt'] -/** Build an ACP invalid-params error with visible human detail. */ +/** Preserve invalid-parameter detail in the SDK wire error message. */ function invalidParams(detail: string): RequestError { return RequestError.invalidParams(undefined, detail) } -/** Build an ACP internal error with visible detail; plain handler errors are flattened on wire. */ +/** Preserve failed-turn detail; plain handler errors become a generic wire internal error. */ function internalError(detail: string): RequestError { return RequestError.internalError(undefined, detail) } @@ -210,7 +208,7 @@ export interface AcpConfig { provider?: string /** Model name for created agents (must have a registered adapter). */ model?: string - /** Runtime-only transport override for tests; production uses stdio. */ + /** Runtime-only transport override; production uses stdio. */ stream?: Stream } @@ -246,13 +244,12 @@ interface ModelCatalogEntry { /** Per-session bridge state keyed by ACP session id. */ interface SessionRecord { - sessionId: SessionId agent: Agent - /** Owned-agent disposer that reaches per-session quiescence. */ + /** Exact owned-agent disposer; resolves after registry, loop, and session teardown. */ dispose: () => Promise - /** Per-session tool presenter and in-flight call correlation. */ + /** Per-session tool presentation and call/result correlation. */ presenter: ToolPresenter - /** Session-creation snapshot of terminal-card support for call/result consistency. */ + /** Terminal capability snapshot shared by matching call and result updates. */ terminalEnabled: boolean /** Session-local provider/model selection and the current step snapshot. */ target: LlmTargetRef @@ -262,10 +259,7 @@ interface SessionRecord { reject: (error: Error) => void turn: number | undefined } | undefined - /** - * Idle config changes awaiting a turn-enclosed log anchor; last write wins. - * Responses overlay them, but a restart before anchoring restores the logged fold. - */ + /** Last idle switch per knob, anchored before the next prompt assembles. */ pendingSwitches: { preset?: string } } @@ -276,14 +270,15 @@ interface SessionRecord { * correlation in a `finally` so presentation failure cannot starve settlement. */ export function apply(ctx: Context, config: AcpConfig): void { - // Handlers run later outside this injection scope, so capture services now. + // ACP handlers execute outside this plugin's injection scope, so capture + // injected services during apply(); lazy service reads in a handler fail. const agents = ctx.agents const llm = ctx.llm const sessionPersistence = ctx.sessionPersistence const logger = ctx.logger const tools = ctx.tools const userInteraction = ctx.userInteraction - // Presenter failures are logged and contained per session or replay. + // Presenter callbacks are contained so display failures cannot break protocol handling. const makePresenter = (agent?: Agent): ToolPresenter => new ToolPresenter(tools, (message) => { logger.warn(message) }, agent) /** Resolve a complete target only; partial config remains available to other request listeners. */ @@ -381,16 +376,12 @@ export function apply(ctx: Context, config: AcpConfig): void { } } - // TODO(derive-acp-session-id): derive event ids from `agent.session`, verify ownership, then remove the reverse map. - // Agent events currently carry only the Agent, so retain `SessionRecord.sessionId` and update both indexes together. - // Dropping the forward record lets the weak reverse entry expire. const sessions = new Map() - const bySession = new WeakMap() - // Reserve ids across asynchronous resume; distinct ids still load concurrently. + // Reserve an id before resume so pipelined load/new requests cannot duplicate it. const loadingIds = new Set() - // Post-await checks prevent a closing bridge from publishing resumed sessions. + // Async creation checks this after awaits to avoid publishing after teardown. let closed = false - // Connection-level capability copied into each new session record. + // Each new or loaded session snapshots the latest connection capability. let terminalOutputCap = false // Assigned at the bottom, before any agent event can fire (a session only @@ -398,20 +389,26 @@ export function apply(ctx: Context, config: AcpConfig): void { // `notify` never observes it unset — no undefined guard needed. let conn: AgentSideConnection + /** Return the bridge-owned record for an agent, rejecting same-id impostors. */ + const ownedRecord = (agent: Agent): SessionRecord | undefined => { + const rec = sessions.get(agent.session.id) + return rec?.agent === agent ? rec : undefined + } + userInteraction.registerProvider({ async ask(request: AskUserQuestionRequest): Promise { if (request.agent === undefined) { throw new UserInteractionError('ACP user questions must come from an agent-owned request', 'NO_AGENT') } - const sessionId = bySession.get(request.agent) - if (sessionId === undefined) { + const rec = ownedRecord(request.agent) + if (rec === undefined) { throw new UserInteractionError('ACP user question has no matching session', 'NO_SESSION') } const answers: AskUserQuestionAnswerItem[] = [] for (const question of request.questions) { const options = question.options ?? [] const response = await withAbort(conn.unstable_createElicitation( - elicitationForQuestion(sessionId, question, options), + elicitationForQuestion(rec.agent.session.id, question, options), ), request.signal).catch((error: unknown) => { if (error instanceof UserInteractionError) throw error throw new UserInteractionError('ACP elicitation request failed', 'ASK_FAILED', { cause: error }) @@ -506,13 +503,13 @@ export function apply(ctx: Context, config: AcpConfig): void { // whose end arrives late is ignored (see // SessionRecord.inflight). A turn that ends `error` REJECTS the prompt (ACP // has no error stop reason); other reasons resolve via the codec. Demux - // strictly by session id: a `session/event` is routed to its own record, so - // two sessions streaming at once never cross-settle or interleave updates. + // strictly by session id: concurrent updates may alternate on the shared + // connection, but they retain the owning id and never cross-settle. ctx.on('session/event', (session, event: SessionEvent) => { const rec = sessions.get(session.header.id) if (rec === undefined) return try { - streamSessionEventUpdate(rec.sessionId, event, notify, rec.presenter, { + streamSessionEventUpdate(rec.agent.session.id, event, notify, rec.presenter, { enabled: rec.terminalEnabled, cwd: session.header.cwd, }, { includeUserMessages: false }) @@ -543,12 +540,12 @@ export function apply(ctx: Context, config: AcpConfig): void { // allow_always is a grant-storage design the approval RFC defers, so the // prompt never offers a durable grant the harness could not honor. ctx.on('approval/request', (req, next) => { - const sessionId = bySession.get(req.agent) + const rec = ownedRecord(req.agent) // The protocol requires `toolCall` (the prompt renders attached to it), so // a request without a callId has nothing to attach to — delegate. - if (sessionId === undefined || req.callId === undefined) return next() + if (rec === undefined || req.callId === undefined) return next() return conn.requestPermission({ - sessionId, + sessionId: rec.agent.session.id, toolCall: { toolCallId: req.callId }, options: [ { optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' }, @@ -577,26 +574,19 @@ export function apply(ctx: Context, config: AcpConfig): void { return [...options, { id: 'permission', name: 'Permissions', - description: 'Sets this session\'s sandbox and approval behavior.', + description: 'The session permission preset: each choice bundles a sandbox mode and an approval policy.', category: 'mode', type: 'select', currentValue, options: [ ...presets.names.map((name: string) => presets.optionOf(name)), - // `custom` is offered only as the current-value echo, never as a target. + // `custom` echoes the current derived state but is never a target. ...currentValue === 'custom' ? [presets.optionOf('custom')] : [], ], }] } - /** - * Whether the session's log currently has an open turn — the last boundary - * event is a `turn/start`. Decides whether a config switch may append NOW - * (enclosed) or must wait for the next prompt submission (see - * {@link SessionRecord.pendingSwitches}). Read from the LOG, not - * `agent.status`: status stays `running` across the gap between two queued - * turns, where a bare append would still land outside any turn. - */ + /** Whether the log has an open turn in which a config switch can be enclosed. */ const isTurnOpen = (agent: Agent): boolean => { const events = agent.session.events for (let index = events.length - 1; index >= 0; index -= 1) { @@ -607,29 +597,22 @@ export function apply(ctx: Context, config: AcpConfig): void { return false } - /** - * Anchor a pending preset in the open turn. `PermissionService.set()` skips - * net-zero changes, so the log records switches rather than select clicks. - */ + /** Anchor last-write-wins idle switches into a just-opened turn. */ const flushPendingSwitches = (rec: SessionRecord): void => { const pending = rec.pendingSwitches rec.pendingSwitches = {} if (pending.preset === undefined) return const presets = ctx.get('permission') /* v8 ignore next -- a pending preset exists only if the service answered the - switch; a valid composition cannot unmount it before anchoring. */ + switch; it cannot unmount between that and the next turn in any composition. */ if (presets === undefined) return presets.set(rec.agent.session, pending.preset) } - // Anchor idle switches on the next prompt submission: its turn is open, but - // request assembly has not begun. This handler runs outside log emission, so - // invariants and persistence observe the events in log order; the first flush - // clears pending state. Promptless injection turns leave the switch pending, - // with no request or execution under stale settings. + // Prompt-submit is inside the new turn but before prompt assembly. Promptless + // injection turns leave the switch pending because they execute no request. ctx.on('agent/prompt-submit', (agent, _content, _source, next) => { - const sessionId = bySession.get(agent) - const rec = sessionId === undefined ? undefined : sessions.get(sessionId) + const rec = ownedRecord(agent) if (rec !== undefined) flushPendingSwitches(rec) return next() }) @@ -677,25 +660,20 @@ export function apply(ctx: Context, config: AcpConfig): void { const directory = modelDirectory(await readModelCatalog(), target.current) assertOpen() const handle = await agents.create({ - agentId: AgentId(sessionId), sessionId, meta: { cwd: params.cwd }, agentOptions: agentOptions(config), setup: (agentCtx) => { installTarget(agentCtx, target) }, }) - // Creation awaits the unpublished setup transaction. A client disconnect - // can therefore close this bridge - // after the entry check but before the handle resolves; never install a - // post-close record that quiesce() could not have seen. + // Agent creation may resolve after the bridge closes; dispose the handle + // instead of publishing a record that teardown could not observe. /* v8 ignore next 4 -- the in-memory transport rejects the in-flight RPC immediately on close; real stdio may let the handler resume */ if (closed) { await handle.dispose() throw internalError('connection closed during session/new') } - bySession.set(handle.agent, sessionId) sessions.set(sessionId, { - sessionId, agent: handle.agent, dispose: () => handle.dispose(), presenter: makePresenter(handle.agent), @@ -753,7 +731,6 @@ export function apply(ctx: Context, config: AcpConfig): void { assertOpen() const target: LlmTargetRef = { current: configuredTarget(), assembled: undefined } const handle = await agents.resume({ - agentId: AgentId(sessionId), resumeSessionId: sessionId, agentOptions: agentOptions(config), setup: (agentCtx) => { installTarget(agentCtx, target) }, @@ -774,13 +751,11 @@ export function apply(ctx: Context, config: AcpConfig): void { } const directory = modelDirectory(catalog, target.current) const agent = handle.agent - bySession.set(agent, sessionId) // Snapshot the terminal capability ONCE for this session (used by both // the replay below and the post-load live stream) so a later // `initialize` can't desync the call/result of a tool card. const terminalEnabled = terminalOutputCap const record: SessionRecord = { - sessionId, agent, dispose: () => handle.dispose(), presenter: makePresenter(agent), @@ -899,8 +874,7 @@ export function apply(ctx: Context, config: AcpConfig): void { if (presets === undefined) { throw invalidParams(`unknown permission value ${JSON.stringify(params.value)}`) } - // Clients may re-send the current selection on session start. Accept - // that echo without logging; this is the only valid `custom` request. + // A current-value echo is acknowledged without recording a switch. const current = rec.pendingSwitches.preset ?? presets.current(rec.agent.session.events) if (params.value === current) break if (!presets.names.includes(params.value)) { @@ -1083,7 +1057,7 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void { * zero or more times per event (best-effort UI feed, never load-bearing). * @param presenter - resolves tool-owned render intent for tool events; * defaults to the generic-fallback {@link nullToolPresenter}. - * @param terminal - the connection's terminal-rendering context; defaults to + * @param terminal - the session's terminal-rendering context; defaults to * disabled (the plain-text console-block fallback). * @param options - `includeUserMessages` (default `true`): live streaming * passes `false` so a prompt the client just sent is not echoed back. @@ -1142,16 +1116,16 @@ export function streamSessionEventUpdate( } /** - * Map a whole harness todo list to an ACP plan, assigning medium priority. - * Statuses map directly and ACP replaces its whole plan on each update. - * @param todos - the harness todo list (the whole list, not a diff). - * @returns the ACP plan body, one entry per todo. + * Map a whole harness todo list to an ACP replacement plan, using medium + * priority because harness todos do not carry one. + * @param todos - complete harness todo list. + * @returns one ACP plan entry per todo. */ export function todosToPlan(todos: TodoItem[]): Plan { return { entries: todos.map((todo): PlanEntry => ({ content: todo.content, priority: 'medium', status: todo.status })) } } -/** Terminal-card capability and workspace context for event rendering. */ +/** Per-session terminal capability and workspace used while translating updates. */ export interface TerminalRendering { enabled: boolean /** The session workspace cwd (terminal-card header default); `undefined` when the session has none. */ @@ -1162,31 +1136,31 @@ export interface TerminalRendering { const noTerminalRendering: TerminalRendering = { enabled: false, cwd: undefined } /** - * Resolve tool-owned call/result views with generic fallbacks. Per-session - * call-id state supplies the tool name and arguments omitted from result events. - * Each entry is consumed by its result; any remainder dies with the session. + * Resolve tool-owned call/result views with a generic fallback. Per-session + * state correlates results with call arguments; interrupted calls may retain an + * entry only until that session's presenter is discarded. */ export class ToolPresenter { private readonly pending = new Map() /** - * @param tools the registry to resolve tool definitions by name. - * @param onError receives contained presenter failures before generic fallback. + * @param tools - registry used to resolve executing definitions. + * @param onError - contained presenter-error sink before generic fallback. + * @param agent - optional scoped registry view for the executing agent. */ constructor( private readonly tools: Pick, private readonly onError: (message: string) => void = () => {}, - /** Agent scope for tool lookup; absent during replay without a live agent. */ private readonly agent?: Agent, ) {} /** - * Resolve a pending call and remember its state for the matching result. + * Pending-state render intent for a `tool/call`; remembers `(name, args, card)` + * for the matching result. * @param callId - the call id the matching `tool/result` will look up. * @param name - the tool name, resolved against the registry for `presentCall`. - * @param argsJson - the raw arguments JSON from the event; parsed for the view - * (a non-JSON string is surfaced raw). - * @returns the tool-owned view, or a generic parsed-input fallback. + * @param argsJson - raw event arguments parsed for presentation. + * @returns the tool-owned view or generic fallback. */ call(callId: CallId, name: string, argsJson: string): ToolCallView { const args = parseToolArguments(argsJson) @@ -1198,22 +1172,20 @@ export class ToolPresenter { this.onError(`acp: tool "${name}" presentCall threw, using generic presentation: ${String(error)}`) present = undefined } - // No tool-owned presentation: fall back to the tool name as the title, the - // full parsed args as the raw input, and kind `other` (the generic card). - // The kind is never sniffed from the name — the bridge does not special-case - // tool names; a tool that wants a richer kind declares `presentCall`. + // Tool names never imply presentation kind; richer cards are tool-owned. const view: ToolCallView = present ?? { card: 'generic', title: name, kind: 'other', rawInput: args } this.pending.set(callId, { name, args, card: view.card }) return view } /** - * Resolve a completed result and consume its remembered call state. + * Completed-state render intent for a `tool/result`; consumes the remembered + * `(name, args, card)`. * @param callId - matching call id; unknown or late ids use raw content. - * @param content - the result's content blocks (the fallback and fill-in body). + * @param content - result content used by the fallback and fill-in body. * @param isError - whether the result is an error, forwarded to `presentResult`. * @param meta - the result's machine-readable meta, forwarded when present. - * @returns the normalized tool-owned view, or a raw-content generic fallback. + * @returns a normalized tool-owned view or raw-content fallback. */ result(callId: CallId, content: ContentBlock[], isError: boolean, meta?: unknown): ToolResultView { const call = this.pending.get(callId) @@ -1283,11 +1255,11 @@ type AcpToolCallContent = | { type: 'diff'; path: string; oldText: string | null; newText: string } | { type: 'terminal'; terminalId: string } -/** Relativize an in-workspace file path in a card title; keep target paths raw. */ +/** Relativize only in-workspace title text; location and diff paths stay raw. */ function displayTitle(title: string, rawPath: string | undefined, sessionCwd: string | undefined): string { if (rawPath === undefined || sessionCwd === undefined || !isAbsolute(rawPath) || !isAbsolute(sessionCwd)) return title const rel = relativePath(sessionCwd, rawPath) - // Reject an empty relative path or a leading parent-directory segment. + // Test the `..` segment, not a character prefix: `..cache/x` is in-workspace. if (rel.length === 0 || rel === '..' || rel.startsWith(`..${pathSep}`)) return title return title.split(rawPath).join(rel) } diff --git a/packages/ui/acp/tests/approval.spec.ts b/packages/ui/acp/tests/approval.spec.ts index ed035aaf80..65679cd905 100644 --- a/packages/ui/acp/tests/approval.spec.ts +++ b/packages/ui/acp/tests/approval.spec.ts @@ -4,9 +4,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { CallId } from '@deepseek-ai/dsh-llm' -import { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import { type Agent } from '@deepseek-ai/dsh-agent' + import ApprovalService, { type ApprovalRequest } from '@deepseek-ai/dsh-user-approval' import { makeBridgeHarness, type BridgeHarness } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * The bridge's `approval/request` answerer: an ask for an agent the bridge @@ -31,7 +33,7 @@ describe('acp bridge — approval answerer', () => { ): Promise<{ agent: Agent; request: ApprovalRequest }> { await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = h.ctx.agents.get(AgentId(sessionId)) + const agent = h.ctx.agents.get(SessionId(sessionId)) if (agent === undefined) throw new Error('newSession created no agent') // In production an ask always fires mid-turn (tool execution); open one so // request()'s turn-enclosure precondition holds for the direct drive below. @@ -88,9 +90,12 @@ describe('acp bridge — approval answerer', () => { await harness.ctx.plugin(ApprovalService) harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } }) - // Not created through the bridge: no bySession entry, so the answerer must - // call next() — nobody else answers, so the seam fails closed. - const foreign = { session: { events: [{ type: 'turn/start' }], append: () => ({}) } } as unknown as Agent + const { agent } = await ownedAgentRequest(harness) + // Even an impostor that claims the bridge-owned session id must delegate: + // ownership requires the exact Agent object stored in the session record. + const foreign = { + session: { id: agent.session.id, events: [{ type: 'turn/start' }], append: () => ({}) }, + } as unknown as Agent await expect(harness.ctx.approval.request({ agent: foreign, toolName: 'echo', callId: CallId('c') })) .resolves.toBe('unavailable') expect(harness.permissionRequests).toHaveLength(0) diff --git a/packages/ui/acp/tests/bridge.spec.ts b/packages/ui/acp/tests/bridge.spec.ts index be05a09644..e7170c380f 100644 --- a/packages/ui/acp/tests/bridge.spec.ts +++ b/packages/ui/acp/tests/bridge.spec.ts @@ -3,8 +3,8 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' -import { AgentId } from '@deepseek-ai/dsh-agent' import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * End-to-end bridge specs over an in-memory transport: a real @@ -98,7 +98,7 @@ describe('acp bridge', () => { required: [], }, }) - const toolResult = harness.ctx.agents.get(AgentId(sessionId))!.session.events.find(event => event.type === 'tool/result') + const toolResult = harness.ctx.agents.get(SessionId(sessionId))!.session.events.find(event => event.type === 'tool/result') const toolResultBlock = toolResult?.type === 'tool/result' ? toolResult.data.content[0] : undefined const toolResultText = toolResultBlock?.type === 'text' ? toolResultBlock.text : undefined expect(toolResultText).toBe('{"answers":[{"id":"language","selected":["Python"]}]}') @@ -127,7 +127,7 @@ describe('acp bridge', () => { required: ['custom'], }, }) - const toolResult = harness.ctx.agents.get(AgentId(sessionId))!.session.events.find(event => event.type === 'tool/result') + const toolResult = harness.ctx.agents.get(SessionId(sessionId))!.session.events.find(event => event.type === 'tool/result') expect(JSON.stringify(toolResult)).toContain('apollo') }) @@ -136,7 +136,7 @@ describe('acp bridge', () => { harness.onElicitation = () => ({ action: 'accept', content: { custom: 'Use Zig' } }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! const result = await harness.ctx.userInteraction.ask({ agent, @@ -167,7 +167,7 @@ describe('acp bridge', () => { harness.onElicitation = () => ({ action: 'accept', content: { choice: 'TypeScript', custom: 'Use Zig' } }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! await expect(harness.ctx.userInteraction.ask({ agent, @@ -184,7 +184,7 @@ describe('acp bridge', () => { harness.onElicitation = () => ({ action: 'accept', content: { choice: ['Tests', 'Docs'] } }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! await expect(harness.ctx.userInteraction.ask({ agent, @@ -201,11 +201,12 @@ describe('acp bridge', () => { harness = await makeBridgeHarness({ storageDir, withAskUser: true }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! await expect(harness.ctx.userInteraction.ask({ questions: [{ id: 'x', question: 'No agent?' }] })) .rejects.toMatchObject({ name: 'UserInteractionError', code: 'NO_AGENT' }) - await expect(harness.ctx.userInteraction.ask({ agent: { id: 'other' } as typeof agent, questions: [{ id: 'x', question: 'No session?' }] })) + const impostor = { session: { id: agent.session.id } } as typeof agent + await expect(harness.ctx.userInteraction.ask({ agent: impostor, questions: [{ id: 'x', question: 'No session?' }] })) .rejects.toMatchObject({ code: 'NO_SESSION' }) harness.onElicitation = () => ({ action: 'cancel' }) @@ -225,7 +226,7 @@ describe('acp bridge', () => { harness = await makeBridgeHarness({ storageDir, withAskUser: true }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! const alreadyAborted = new AbortController() alreadyAborted.abort() @@ -265,8 +266,8 @@ describe('acp bridge', () => { expect(b.sessionId).toBeTruthy() expect(a.sessionId).not.toBe(b.sessionId) // Both agents are live and independently registered. - expect(harness.ctx.agents.get(AgentId(a.sessionId))).toBeDefined() - expect(harness.ctx.agents.get(AgentId(b.sessionId))).toBeDefined() + expect(harness.ctx.agents.get(SessionId(a.sessionId))).toBeDefined() + expect(harness.ctx.agents.get(SessionId(b.sessionId))).toBeDefined() }) it('rejects a non-absolute cwd but accepts any absolute cwd (per-session workspace)', async () => { @@ -281,7 +282,7 @@ describe('acp bridge', () => { const res = await harness.client.newSession({ cwd: '/tmp', mcpServers: [] }) expect(res.sessionId).toBeTruthy() // The session header records that cwd, so its bash tools run there. - expect(harness.ctx.agents.get(AgentId(res.sessionId))!.session.header.cwd).toBe('/tmp') + expect(harness.ctx.agents.get(SessionId(res.sessionId))!.session.header.cwd).toBe('/tmp') }) it('rejects non-empty additionalDirectories', async () => { @@ -321,7 +322,7 @@ describe('acp bridge', () => { ], }) expect(result.stopReason).toBe('end_turn') - const user = harness.ctx.agents.get(AgentId(sessionId))!.session.events.find(event => event.type === 'user/message') + const user = harness.ctx.agents.get(SessionId(sessionId))!.session.events.find(event => event.type === 'user/message') expect(JSON.stringify(user)).toContain('resource_link') }) diff --git a/packages/ui/acp/tests/config-options.spec.ts b/packages/ui/acp/tests/config-options.spec.ts index 806aaa3ab6..3611c55e1f 100644 --- a/packages/ui/acp/tests/config-options.spec.ts +++ b/packages/ui/acp/tests/config-options.spec.ts @@ -29,7 +29,7 @@ function permissionOption(currentValue: string): object { return { id: 'permission', name: 'Permissions', - description: 'Sets this session\'s sandbox and approval behavior.', + description: 'The session permission preset: each choice bundles a sandbox mode and an approval policy.', category: 'mode', type: 'select', currentValue, diff --git a/packages/ui/acp/tests/dispose.spec.ts b/packages/ui/acp/tests/dispose.spec.ts index 30faf9aafe..5cbdb6859b 100644 --- a/packages/ui/acp/tests/dispose.spec.ts +++ b/packages/ui/acp/tests/dispose.spec.ts @@ -4,7 +4,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { SessionId } from '@deepseek-ai/dsh-session' -import { AgentId } from '@deepseek-ai/dsh-agent' import { makeBridgeHarness, textResponse } from './harness.ts' describe('acp bridge — disposal & HMR safety', () => { @@ -17,25 +16,29 @@ describe('acp bridge — disposal & HMR safety', () => { const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! // Start a prompt that hangs in the model stream. const promptDone = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') - // Teardown must abort and await the loop: once it resolves the agent is settled, and the - // hanging prompt itself completes as cancelled rather than remaining pending. + // Dispose the whole context. The bridge's teardown must abort the agent and + // AWAIT whenIdle() — so right after dispose resolves, the agent is settled + // (not still running). Proves disposal waited, not just requested. await harness.ctx.fiber.dispose() expect(agent.status).not.toBe('running') + // The in-flight prompt settled (cancelled) rather than hanging forever. const res = await promptDone expect(res.stopReason).toBe('cancelled') }) it('after an ACP-only HMR dispose, a late session/new creates no orphan agent (closed guard)', async () => { - // Unload only the bridge while transport and shared services remain live. Its closed guard must - // reject late creation before an orphan agent can enter the registry. + // Dispose JUST the bridge's fiber (an HMR reload) while agents/agent-loop + // stay up and the transport is still live. A late session/new must hit the + // `closed` guard and reject — NOT create an agent the disposed bridge can no + // longer stream or settle. Verify the world: no agent appeared. const harness = await makeBridgeHarness({ storageDir, script: [] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const before = harness.ctx.agents.list().length @@ -47,21 +50,29 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('an agent created through the bridge is unregistered when ONLY the bridge fiber is disposed', async () => { - // The traced service proxy binds loop registration to the caller (bridge) fiber. ACP-only - // disposal must therefore reclaim the agent even while agent-loop itself remains mounted. + // The factory (`ctx.agents.create`) is reached through the bridge's + // traceable service proxy, so `AgentLoop.start`'s `this.ctx.effect(...)` + // registration binds to the CALLER context — the bridge fiber — not the + // AgentLoop fiber. Disposing JUST the bridge fiber (an ACP-only HMR reload) + // must therefore reclaim the agent's registry entry, even though agents/ + // agent-loop stay up. This pins the fiber-ownership the bridge's teardown + // doc comment relies on; if a refactor rebinds the registration to the + // AgentLoop fiber, the agent would survive bridge dispose and this fails. const harness = await makeBridgeHarness({ storageDir, script: [] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - expect(harness.ctx.agents.get(AgentId(sessionId))).toBeDefined() + expect(harness.ctx.agents.get(SessionId(sessionId))).toBeDefined() await harness.acpFiber.dispose() // tear down ONLY the bridge - expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined() + expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() await harness.dispose() }) it('no agent is created by a session/new after the bridge has closed (closed guard)', async () => { - // Disconnect sets the closed guard and severs the RPC, so registry state—not the rejection - // shape—proves a late request did not create an undriveable agent. + // After teardown (here a client disconnect sets `closed`), a late + // `session/new` must NOT create an orphan agent the bridge can no longer + // drive/settle. The transport is gone so the RPC rejects; assert the world: + // no new agent appeared in the registry. const harness = await makeBridgeHarness({ storageDir, script: [] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const before = harness.ctx.agents.list().length @@ -73,43 +84,59 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('a client disconnect mid-prompt disposes the session (no registered agent left)', async () => { - // Disconnect mid-stream must dispose, not merely idle, the owned agent; otherwise updates would - // be swallowed while a registered session survived without a client. + // The ACP transport closes (editor quits) while a turn runs. The bridge must + // settle the in-flight prompt cancelled and DISPOSE the agent (the session's + // per-agent AgentHandle teardown) rather than leaving an orphaned running — + // or even idled-but-still-registered — agent whose updates are swallowed. const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(AgentId(sessionId))! - // The transport will close before this hanging RPC settles. + const agent = harness.ctx.agents.get(SessionId(sessionId))! + // Start a prompt that hangs in the model stream. The prompt RPC will never + // return (its transport is severed), so do not await it. void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {}) await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') + // Sever the transport — the bridge's conn.closed teardown runs and drives the + // agent's AgentHandle dispose to quiescence on its OWN (before any dispose()). await harness.closeClientTransport() await agent.whenIdle() + // The agent's loop has stopped: status `disposed`. expect(agent.status).toBe('disposed') - // Await the same memoized bridge teardown without removing root services. It must finish the - // AgentHandle teardown and remove both registry records, not just stop the loop. + // Await the bridge teardown to completion WITHOUT tearing down the root + // agents/sessions services (so we can still query them). acpFiber.dispose() + // invokes the SAME memoized quiesce() the disconnect started and awaits its + // promise — which resolves only after every rec.dispose() (loop exit + + // session removal) has finished, closing the whenIdle()/owned.dispose() + // microtask race. The AgentHandle dispose has run: the agent is unregistered + // and its session removed from the store, not merely idled (the old + // behavior). The services live on the root ctx, so they survive this. await harness.acpFiber.dispose() - expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined() + expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined() await harness.dispose() }) it('a client disconnect racing fiber dispose both reach quiescence (shared teardown)', async () => { - // Transport close and fiber disposal can race. Both must await one memoized teardown; a guard - // based only on record removal could let the second caller return while the first still drains. + // conn.closed teardown and ctx.fiber.dispose() can fire near-simultaneously. + // They must share one teardown promise: dispose() must NOT return before the + // disconnect teardown's whenIdle() has settled (a `record === undefined`-only + // guard would let the second caller return early mid-teardown). const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {}) await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') + // Fire both teardown paths without awaiting the first, then await both. const close = harness.closeClientTransport() const dispose = harness.ctx.fiber.dispose() await Promise.all([close, dispose]) + // After BOTH settle, the agent has fully drained (not still running). expect(agent.status).not.toBe('running') }) @@ -117,7 +144,7 @@ describe('acp bridge — disposal & HMR safety', () => { const harness = await makeBridgeHarness({ storageDir, script: [] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const session = harness.ctx.agents.get(AgentId(sessionId))!.session + const session = harness.ctx.agents.get(SessionId(sessionId))!.session await harness.ctx.fiber.dispose() const before = harness.updates.length @@ -129,18 +156,27 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('the final turn closing events are persisted across an AgentHandle dispose (durability)', async () => { - // AgentHandle teardown stops and awaits the loop, flushes through still-attached store hooks, - // then detaches the session. Reloading verifies that order from durable state. + // The teardown-ORDER guarantee: a per-agent dispose must stop the loop, + // AWAIT its exit (so the loop's final `turn/end` + `session/flush` fire + // through the still-attached store observer → `session/event`), and only + // THEN remove its publication hooks and session entry. If the order were inverted + // (detach first), the closing events would never reach persistence. Drive a + // CLEAN turn to completion, dispose JUST the bridge, then re-load the + // persisted log from disk and assert the closing turn/end is on disk — the + // world, not the agent's self-report. const harness = await makeBridgeHarness({ storageDir, script: [textResponse('done')] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) - const liveEvents = harness.ctx.agents.get(AgentId(sessionId))!.session.events.length + const liveEvents = harness.ctx.agents.get(SessionId(sessionId))!.session.events.length expect(liveEvents).toBeGreaterThan(0) + // Tear down JUST the bridge (the AgentHandle dispose runs to quiescence). await harness.acpFiber.dispose() - expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined() + expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() + // Re-load the session from disk: every live event (incl. the closing + // turn/end) was flushed before the session was detached. const reloaded = await harness.ctx.sessionPersistence.load(SessionId(sessionId)) expect(reloaded.events.length).toBe(liveEvents) const last = reloaded.events.at(-1)! @@ -149,20 +185,35 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('a turn aborted BY the dispose still flushes its closing turn/end to disk (durability, mid-turn)', async () => { - // Here disposal itself makes the loop append `turn/end {disposed}` and flush. Reload must find - // that real closer, not crash recovery's synthetic `interrupted`, proving detach ran last. + // The teardown-order contract only earns its keep when the closing events are + // produced BY the dispose itself. Here the model stream HANGS, so the turn is + // still open when teardown runs: the composite agent effect stops the loop, + // the loop unwinds and appends `turn/end {disposed}` + runs its final + // `session/flush` — all while the store-owned publication hooks are still attached (the session + // detach is the LAST disposer in the same effect's LIFO chain) — and only + // THEN is the session detached. If the order were inverted (or the session + // were a racing SIBLING effect), the abort-produced `turn/end` would never + // reach disk and a re-load would instead show crash-recovery's synthetic + // `interrupted` closer. Re-load from disk and assert the REAL `disposed` + // reason landed — proving the loop's own closing event was captured, not a + // recovered substitute. const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {}) await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') + // The turn is OPEN in the log (turn/start appended, no turn/end yet). const openTurnEnds = agent.session.events.filter(e => e.type === 'turn/end').length + // Dispose JUST the bridge: a fiber unload that must STILL honor the ordered + // teardown (the composite effect runs its disposer chain as a unit). await harness.acpFiber.dispose() - expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined() + expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() + // The loop's own `turn/end {disposed}` is on disk (re-load: the world, not + // self-report) — NOT a crash-recovery `interrupted` substitute. const reloaded = await harness.ctx.sessionPersistence.load(SessionId(sessionId)) const persistedTurnEnds = reloaded.events.filter(e => e.type === 'turn/end') expect(persistedTurnEnds.length).toBe(openTurnEnds + 1) @@ -171,55 +222,71 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('per-session AgentHandle dispose leaves sibling agents untouched', async () => { - // A per-session handle owns exactly one agent and session. Dispose A and assert B remains fully - // published, which guards against context-wide teardown. + // The factory returns a per-agent AgentHandle whose dispose() tears down + // EXACTLY that agent + its session — RFC 011 isolation. Create two agents + // directly through the registry factory (the same path the ACP bridge uses), + // dispose one handle, and assert the other survives, registered and + // queryable, with its session still in the store. const harness = await makeBridgeHarness({ storageDir, script: [] }) const handleA = await harness.ctx.agents.create({ - agentId: AgentId('sib-a'), sessionId: SessionId('sib-a'), agentOptions: { provider: 'mock', model: 'mock' }, + sessionId: SessionId('sib-a'), agentOptions: { provider: 'mock', model: 'mock' }, }) const handleB = await harness.ctx.agents.create({ - agentId: AgentId('sib-b'), sessionId: SessionId('sib-b'), agentOptions: { provider: 'mock', model: 'mock' }, + sessionId: SessionId('sib-b'), agentOptions: { provider: 'mock', model: 'mock' }, }) - expect(harness.ctx.agents.get(AgentId('sib-a'))).toBe(handleA.agent) - expect(harness.ctx.agents.get(AgentId('sib-b'))).toBe(handleB.agent) + expect(harness.ctx.agents.get(SessionId('sib-a'))).toBe(handleA.agent) + expect(harness.ctx.agents.get(SessionId('sib-b'))).toBe(handleB.agent) await handleA.dispose() - expect(harness.ctx.agents.get(AgentId('sib-a'))).toBeUndefined() + // A is gone — unregistered AND its session removed from the store. + expect(harness.ctx.agents.get(SessionId('sib-a'))).toBeUndefined() expect(harness.ctx.sessions.get(SessionId('sib-a'))).toBeUndefined() expect(handleA.agent.status).toBe('disposed') - expect(harness.ctx.agents.get(AgentId('sib-b'))).toBe(handleB.agent) + // B is wholly unaffected. + expect(harness.ctx.agents.get(SessionId('sib-b'))).toBe(handleB.agent) expect(harness.ctx.sessions.get(SessionId('sib-b'))).toBeDefined() expect(handleB.agent.status).not.toBe('disposed') await harness.dispose() }) it('a throwing agent/disposed listener does not prevent session removal (composite-effect containment)', async () => { - // Composite disposers run in sequence. A throwing `agent/disposed` listener must be contained or - // it would skip later session detach, leaking publication hooks and creating a durability hole. + // The AgentHandle teardown folds session-detach, register, and loop-stop + // into ONE composite effect whose disposers run as a `.then()` chain. The + // register disposer emits `agent/disposed`; if a listener throws and the + // emit is UNCONTAINED, the rejected chain skips the LATER session-detach + // disposer — stranding the session in the store with its publication hooks attached (a + // leak AND a durability hole, since the new design relies on detach + // running). The emit must be contained. Register a throwing listener, drive + // a clean turn, dispose, and assert the session was STILL removed. const harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] }) harness.ctx.on('agent/disposed', () => { throw new Error('boom disposed listener') }) const handle = await harness.ctx.agents.create({ - agentId: AgentId('guard-a'), sessionId: SessionId('guard-a'), agentOptions: { provider: 'mock', model: 'mock' }, + sessionId: SessionId('guard-a'), agentOptions: { provider: 'mock', model: 'mock' }, }) handle.agent.send([{ type: 'text', text: 'go' }]) await handle.agent.whenIdle() expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeDefined() + // Dispose: the throwing listener must NOT break the chain before detach. await handle.dispose() - expect(harness.ctx.agents.get(AgentId('guard-a'))).toBeUndefined() + expect(harness.ctx.agents.get(SessionId('guard-a'))).toBeUndefined() expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeUndefined() // detach still ran await harness.dispose() }) it('concurrent AgentHandle dispose() calls all await the SAME teardown (memoized)', async () => { - // The Cordis effect disposer is single-shot and would let a second call return after its epoch - // clears. AgentHandle must memoize the whole async teardown so every caller awaits quiescence. + // The handle's dispose() must memoize: the underlying cordis effect disposer + // is single-shot, so a second dispose() while the first is mid-teardown would + // otherwise resolve IMMEDIATELY (effect epoch already cleared) — before the + // first call's await agent.done + final flush finished. Every caller must + // observe the same quiescence boundary. const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) const handle = await harness.ctx.agents.create({ - agentId: AgentId('conc-a'), sessionId: SessionId('conc-a'), agentOptions: { provider: 'mock', model: 'mock' }, + sessionId: SessionId('conc-a'), agentOptions: { provider: 'mock', model: 'mock' }, }) - // A hanging turn makes disposal produce a final flush; gate it so the second call arrives while - // teardown is observably in flight. + // Drive a turn that hangs in the model stream, so the loop is mid-turn when + // disposed — its exit runs a final session/flush we can gate to hold the + // teardown observably in-flight. handle.agent.send([{ type: 'text', text: 'go' }]) await new Promise(r => setTimeout(r, 30)) expect(handle.agent.status).toBe('running') @@ -227,21 +294,25 @@ describe('acp bridge — disposal & HMR safety', () => { const flushGate = new Promise((resolve) => { releaseFlush = resolve }) harness.ctx.on('session/flush', () => flushGate) + // First dispose enters teardown (aborts the hanging step) and blocks in the + // gated final flush. const first = handle.dispose() let firstSettled = false void first.then(() => { firstSettled = true }) await new Promise(r => setTimeout(r, 20)) expect(firstSettled).toBe(false) + // Second dispose MUST await the same in-flight teardown, not resolve early. const second = handle.dispose() let secondSettled = false void second.then(() => { secondSettled = true }) await new Promise(r => setTimeout(r, 20)) expect(secondSettled).toBe(false) // memoized: still pending with the first + // Release the flush; both resolve together and the session is gone. releaseFlush() await Promise.all([first, second]) - expect(harness.ctx.agents.get(AgentId('conc-a'))).toBeUndefined() + expect(harness.ctx.agents.get(SessionId('conc-a'))).toBeUndefined() expect(harness.ctx.sessions.get(SessionId('conc-a'))).toBeUndefined() await harness.dispose() }) diff --git a/packages/ui/acp/tests/edges.spec.ts b/packages/ui/acp/tests/edges.spec.ts index de3a7a6f03..35dd54a634 100644 --- a/packages/ui/acp/tests/edges.spec.ts +++ b/packages/ui/acp/tests/edges.spec.ts @@ -3,7 +3,6 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' -import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' @@ -27,7 +26,7 @@ describe('acp bridge — demux & config edges', () => { await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) const before = harness.updates.length - const { agent: foreign } = await harness.ctx.agents.create({ agentId: AgentId('foreign'), sessionId: SessionId('foreign-session'), agentOptions: { provider: 'mock', model: 'mock' } }) + const { agent: foreign } = await harness.ctx.agents.create({ sessionId: SessionId('foreign-session'), agentOptions: { provider: 'mock', model: 'mock' } }) foreign.send([{ type: 'text', text: 'hi' }]) await foreign.whenIdle() await new Promise(r => setTimeout(r, 10)) diff --git a/packages/ui/acp/tests/load.spec.ts b/packages/ui/acp/tests/load.spec.ts index f57767fb38..b8de5b8557 100644 --- a/packages/ui/acp/tests/load.spec.ts +++ b/packages/ui/acp/tests/load.spec.ts @@ -4,7 +4,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' -import { AgentId } from '@deepseek-ai/dsh-agent' import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts' /** Concatenate the text of all agent_message_chunk updates. */ @@ -185,7 +184,7 @@ describe('acp bridge — session/load replay', () => { release() // resume() finishes AFTER teardown expect(await loadResult).toBe('rejected') // No live agent was installed for the closed connection. - expect(loader.ctx.agents.get(AgentId(sessionId))).toBeUndefined() + expect(loader.ctx.agents.get(SessionId(sessionId))).toBeUndefined() }) it('rejects load when the requested cwd does not match the persisted session cwd', async () => { @@ -205,11 +204,11 @@ describe('acp bridge — session/load replay', () => { await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) await expect(loader.client.loadSession({ sessionId: 'elsewhere', cwd: process.cwd(), mcpServers: [] })) .rejects.toThrow(/cwd mismatch/) - expect(loader.ctx.agents.get(AgentId('elsewhere'))).toBeUndefined() + expect(loader.ctx.agents.get(SessionId('elsewhere'))).toBeUndefined() const res = await loader.client.loadSession({ sessionId: 'elsewhere', cwd: `${otherCwd}/.`, mcpServers: [] }) expect(res).toBeDefined() - expect(loader.ctx.agents.get(AgentId('elsewhere'))!.session.header.cwd).toBe(otherCwd) + expect(loader.ctx.agents.get(SessionId('elsewhere'))!.session.header.cwd).toBe(otherCwd) }) it('rejects load for a non-absolute cwd (still required to be absolute)', async () => { @@ -243,7 +242,7 @@ describe('acp bridge — session/load replay', () => { // Rejected BEFORE resume (metadata-only check) — no agent was registered, so // the id is not wedged: a later attempt hits the same clean rejection, not a // duplicate-registration error. - expect(loader.ctx.agents.get(AgentId('legacy'))).toBeUndefined() + expect(loader.ctx.agents.get(SessionId('legacy'))).toBeUndefined() await expect(loader.client.loadSession({ sessionId: 'legacy', cwd: process.cwd(), mcpServers: [] })) .rejects.toThrow(/no absolute persisted cwd/) }) diff --git a/packages/ui/acp/tests/multi-session.spec.ts b/packages/ui/acp/tests/multi-session.spec.ts index ca11934046..0881fe4199 100644 --- a/packages/ui/acp/tests/multi-session.spec.ts +++ b/packages/ui/acp/tests/multi-session.spec.ts @@ -3,8 +3,8 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' -import { AgentId } from '@deepseek-ai/dsh-agent' import { makeBridgeHarness, textResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** Text of the agent_message_chunk updates scoped to one session id. */ function messageTextFor(updates: { sessionId?: string; update: CapturedUpdate }[], sessionId: string): string { @@ -102,8 +102,8 @@ describe('acp bridge — RFC 011 multi-session isolation', () => { await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId const b = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId - const agentA = harness.ctx.agents.get(AgentId(a))! - const agentB = harness.ctx.agents.get(AgentId(b))! + const agentA = harness.ctx.agents.get(SessionId(a))! + const agentB = harness.ctx.agents.get(SessionId(b))! // Wait deterministically for BOTH agents to enter `running` (not a fixed // sleep — agent startup latency is unbounded on a loaded worker). diff --git a/packages/ui/acp/tests/turns.spec.ts b/packages/ui/acp/tests/turns.spec.ts index 78591ffa76..15c2415449 100644 --- a/packages/ui/acp/tests/turns.spec.ts +++ b/packages/ui/acp/tests/turns.spec.ts @@ -3,7 +3,6 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { defineTool } from '@deepseek-ai/dsh-tools' -import { AgentId } from '@deepseek-ai/dsh-agent' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { errorResponse, @@ -13,6 +12,7 @@ import { toolCallResponse, type BridgeHarness, } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** Boilerplate: initialize + create one session, returning its id. */ async function newSession(h: BridgeHarness, clientCapabilities: Record = {}): Promise { @@ -274,7 +274,7 @@ describe('acp bridge — turn outcomes', () => { // OWN turn with the real model answer. harness = await makeBridgeHarness({ storageDir, script: [textResponse('real answer')] }) const sessionId = await newSession(harness) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! // On the queued prompt, synchronously inject a one-shot context turn (idle // inject writes turn/start{injection} → context/message → turn/end). Fire // once so it lands between install and the prompt turn. @@ -328,7 +328,7 @@ describe('acp bridge — turn outcomes', () => { await harness.client.cancel({ sessionId }) const res = await promptDone expect(res.stopReason).toBe('cancelled') - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! await agent.whenIdle() const turnStarts = agent.session.events.filter(e => e.type === 'turn/start').length expect(turnStarts).toBeLessThanOrEqual(1) diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md index c814919079..6666867598 100644 --- a/packages/ui/jsonrpc/README.md +++ b/packages/ui/jsonrpc/README.md @@ -1,26 +1,26 @@ # @deepseek-ai/dsh-jsonrpc -Stdio JSON-RPC plugin for out-of-process SDK clients such as Python `deepseek_harness`. [`HarnessSdkServer`](src/server.ts) handles `initialize` → `session/prompt` → `shutdown` plus session and subagent notifications over [`JsonRpcLineTransport`](src/transport.ts). This package owns the protocol; [`jsonrpc-agent`](../../examples/jsonrpc-demo/README.md) boots the external `cordis.yml` that chooses the surrounding runtime. See the [single-executable RFC](../../../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) for the distribution design. +The `jsonrpc` plugin serves newline-delimited JSON-RPC over stdio so out-of-process SDK clients can drive harness agents. [`HarnessSdkServer`](src/server.ts) owns the protocol methods and notifications; [`jsonrpc-demo`](../../examples/jsonrpc-demo/README.md) supplies the surrounding `cordis.yml` application. ## Wiring -`inject: ['agents']`. The server gets or creates one agent per `sessionId` from the `initialize.provider`/`initialize.model` pair and demuxes `subagent/end` through the registry. A registered owner for the provider route wins; an unowned `deepseek` route mounts `dsh-llm-deepseek` using `$DEEPSEEK_API_KEY` and `$DEEPSEEK_BASE_URL`, while any other unowned provider fails initialization. Persistence, tools, and other adapters come from the surrounding `cordis.yml`. +`inject: ['agents']`. The server gets or creates one agent per `sessionId`. It forwards subagent completions only when the service-snapshotted lifecycle `local` flag is true; provider names, child ids, and durable lineage never establish locality. A registered adapter wins, an unowned `deepseek` route mounts `dsh-llm-deepseek`, and any other unowned provider fails initialization. Other capabilities come from the surrounding `cordis.yml`. ## Config -No `cordis.yml` keys. `JsonRpcConfig.input`, `output`, and `exit` are test-only runtime seams; production uses process stdio and `process.exit`. +There are no `cordis.yml` keys. `JsonRpcConfig.input`, `output`, and `exit` are runtime-only transport seams; production uses process stdio and `process.exit`. ## stdout is the protocol -stdout carries only JSON-RPC frames. The loading config must omit stdout loggers; diagnostics go to stderr. +Stdout carries only JSON-RPC frames. The deployment must not compose a stdout logger; diagnostics belong on stderr. ## Shutdown and exit semantics -A `shutdown` request flushes its response, disposes the plugin fiber, then exits 0. Disposal idempotently shuts down every SDK-created agent to quiescence, detaches subscriptions, and closes the transport. Bare fiber disposal only stops serving; it does not exit. The app bin owns root disposal for stdin EOF (0), SIGTERM (0), and SIGINT (130). +The plugin answers `shutdown`, disposes SDK-owned agents and subscriptions to quiescence, closes the transport, then exits with code 0. EOF and signal exits belong to the app bin, which disposes the root context. Unloading only this plugin stops serving without exiting the process. ## Wire notes -`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. Each session permits one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. Persistence roots and deployment persona remain in `cordis.yml`. +`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. A session accepts one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. Persistence roots and persona come from `cordis.yml`. ## Model Experience diff --git a/packages/ui/jsonrpc/package.json b/packages/ui/jsonrpc/package.json index bb91ab4fc2..e59fc4aaea 100644 --- a/packages/ui/jsonrpc/package.json +++ b/packages/ui/jsonrpc/package.json @@ -28,6 +28,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-llm-deepseek": "^0.0.1", + "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -38,6 +39,7 @@ "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index 2b3646c731..8949ccb06a 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -1,8 +1,6 @@ /** - * JSON-RPC methods and notifications for SDK clients. Requests are - * `initialize`, repeated `session/prompt`, then `shutdown`; notifications carry - * durable session events, settled turns, and subagent lineage/outcomes. The - * external `cordis.yml` owns plugins, persistence, and the adapter set. + * JSON-RPC method and notification surface for out-of-process harness SDKs. + * The surrounding context owns plugins, persistence, and configured adapters. * * @module @deepseek-ai/dsh-jsonrpc/server */ @@ -10,14 +8,15 @@ import type { Context } from 'cordis' import { resolve } from 'node:path' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { AgentHandle } from '@deepseek-ai/dsh-agent' -import { AgentId } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' +import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope' import { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session' +import type SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import type { JsonRpcTransportPeer } from './transport.ts' -/** One-time SDK initialization parameters. */ +/** Parameters for the process-wide SDK handshake. */ export interface InitializeParams { /** Working directory recorded on every SDK-created session's header. */ cwd: string @@ -27,16 +26,13 @@ export interface InitializeParams { model: string } -/** SDK handshake result. */ +/** Wire-stable server identity returned by initialization. */ export interface InitializeResult { /** Wire-stable server identity (`deepseek-harness-sdk-runtime`) and version. */ serverInfo: { name: string; version: string } } -/** - * Parameters of a `session/prompt` request: one user turn on one SDK session, - * with at most one in flight per session. - */ +/** One user turn on one SDK session. */ export interface SessionPromptParams { /** The SDK-side session id; an unknown id lazily creates the agent+session pair. */ sessionId: string @@ -44,7 +40,7 @@ export interface SessionPromptParams { contentBlocks: ContentBlock[] } -/** Accepted prompt result; the outcome is reported by `session.finished`. */ +/** Prompt acceptance after turn settlement; outcome rides on `session.finished`. */ export interface SessionPromptResult { /** Always `true`; the turn outcome is the paired `session.finished` notification. */ accepted: true @@ -56,16 +52,12 @@ interface SessionRecord { activePrompt: boolean } -interface SubagentRecord { - childSessionId: string - parentSessionId: string | undefined +/** Recover the delegating parent from the service-owned scoped carrier. */ +function subagentParentOf(carrier: Scoped): Agent { + return carrierKeyOf(carrier) as Agent } -/** - * SDK server over one booted harness context and transport peer. Construction - * subscribes to session, agent, and subagent lifecycle events until shutdown; - * reinitialization is unsupported. - */ +/** SDK server whose subscriptions and created agents live until {@link shutdown}. */ export class HarnessSdkServer { private cwd = process.cwd() private provider = 'deepseek' @@ -73,7 +65,6 @@ export class HarnessSdkServer { private llmFiber: { dispose(): Promise } | undefined private readonly sessions = new Map() private readonly sessionCreations = new Map>() - private readonly subagentSessions = new Map() private readonly disposers: (() => void)[] = [] private shutdownTask: Promise> | undefined private shuttingDown = false @@ -97,28 +88,17 @@ export class HarnessSdkServer { childSessionId: String(session.id), }) })) - // Cache lineage before child disposal removes the agent from the registry. - this.disposers.push(ctx.on('agent/created', (agent) => { - this.subagentSessions.set(String(agent.id), { - childSessionId: String(agent.session.id), - parentSessionId: agent.session.header.parentSession === undefined - ? undefined - : String(agent.session.header.parentSession), - }) - })) - this.disposers.push(ctx.on('subagent/end', (info: SubagentRunEndInfo) => { - const rec = this.subagentSessions.get(String(info.id)) - const agent = this.ctx.agents.get(info.id) - const childSessionId = rec?.childSessionId ?? (agent === undefined ? undefined : String(agent.session.id)) - const parentSessionId = rec?.parentSessionId ?? ( - agent?.session.header.parentSession === undefined ? undefined : String(agent.session.header.parentSession) - ) - if (childSessionId === undefined) return - this.transport.notify('subagent.finished', { + this.disposers.push(ctx.on('subagent/end', function (this: Scoped, info: SubagentRunEndInfo) { + const parent = subagentParentOf(this) + // This protocol reports only in-process child sessions. The service + // snapshots the provider's exact run provenance through child disposal; + // matching ids or parent lineage alone never establishes locality. + if (!info.local) return + transport.notify('subagent.finished', { provider: info.provider, agentId: String(info.id), - ...(parentSessionId === undefined ? {} : { parentSessionId }), - childSessionId, + parentSessionId: String(parent.session.id), + childSessionId: String(info.id), status: info.stopReason === 'completed' ? 'ok' : 'error', stopReason: info.stopReason, ...(info.lastAssistantMessage === undefined ? {} : { lastAssistantMessage: info.lastAssistantMessage }), @@ -127,10 +107,9 @@ export class HarnessSdkServer { } /** - * Record cwd and provider/model, mounting the DeepSeek adapter only when the - * `deepseek` provider route has no configured owner. - * @param params - the SDK handshake parameters. - * @returns the server identity for the handshake. + * Configure the SDK route, mounting the DeepSeek fallback only when unowned. + * @param params - SDK handshake parameters. + * @returns server identity for the handshake. */ async initialize(params: InitializeParams): Promise { this.cwd = resolve(params.cwd) @@ -144,11 +123,9 @@ export class HarnessSdkServer { } /** - * Get or create the session agent, send the prompt, await quiescence, then - * notify `session.finished`. A session accepts one prompt at a time; other - * sessions remain independent. - * @param params - the target session id and prompt content. - * @returns `{ accepted: true }` after the turn settled. + * Run one prompt to settlement; overlap on the same session fails. + * @param params - target session and user content. + * @returns acceptance after the turn settled. */ async prompt(params: SessionPromptParams): Promise { const rec = await this.getOrCreateSession(params.sessionId) @@ -171,9 +148,9 @@ export class HarnessSdkServer { } /** - * Dispose SDK-created agents to quiescence, unmount the server-mounted adapter, - * and detach subscriptions. The surrounding context remains running. - * @returns an empty object (the JSON-RPC result). + * Dispose server-owned agents, adapter, and subscriptions to quiescence. + * The surrounding context remains running. + * @returns empty JSON-RPC result. */ shutdown(): Promise> { this.shutdownTask ??= this.performShutdown() @@ -187,7 +164,6 @@ export class HarnessSdkServer { this.sessionCreations.clear() const records = [...this.sessions.values()] this.sessions.clear() - this.subagentSessions.clear() const failures: unknown[] = [] while (this.disposers.length > 0) { try { @@ -210,8 +186,8 @@ export class HarnessSdkServer { } /** - * Dispatch an incoming request; unknown methods throw for transport conversion - * to a JSON-RPC error response. + * Dispatch one incoming JSON-RPC request to its typed handler. Throws (→ a + * JSON-RPC error response) on an unknown method. * @param method - the JSON-RPC method name. * @param params - the raw params object from the wire. * @returns the handler's result, to be serialized as the response. @@ -246,7 +222,6 @@ export class HarnessSdkServer { private async createSession(sessionId: string): Promise { const handle = await this.ctx.agents.create({ - agentId: AgentId(sessionId), sessionId: SessionId(sessionId), meta: { cwd: this.cwd }, agentOptions: { provider: this.provider, model: this.model }, diff --git a/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts b/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts new file mode 100644 index 0000000000..051b0eef20 --- /dev/null +++ b/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts @@ -0,0 +1,122 @@ +/** + * Built-artifact guard for the scope carrier shared by `dsh-subagent` and + * `dsh-jsonrpc`. The carrier registry is module-local, so both bundles must + * externalize `dsh-scope`; source-mode tests cannot expose an accidentally + * inlined second registry. This test runs the real `lib/index.js` bundles in a + * plain Node subprocess, disposes the child before settlement, and requires the + * SDK completion notification to retain the delegating parent. + */ + +import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { describe, expect, it } from 'vitest' + +const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) +const jsonrpcBundle = fileURLToPath(new URL('../lib/index.js', import.meta.url)) +const execFileAsync = promisify(execFile) + +const builtRuntimeProbe = String.raw` +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const load = (path) => import(pathToFileURL(resolve(path)).href); +const [ + { Context }, + agentCore, + { default: SubagentService }, + { default: SessionPersistenceJsonl }, + { HarnessSdkServer }, + { SessionId }, +] = await Promise.all([ + load("vendor/cordis/lib/index.js"), + load("packages/examples/agent-spine-demo/lib/index.js"), + load("packages/subagent/subagent/lib/index.js"), + load("packages/session-persistence/session-persistence-jsonl/lib/index.js"), + load("packages/ui/jsonrpc/lib/index.js"), + load("packages/core/session/lib/index.js"), +]); + +const storageRoot = await mkdtemp(join(tmpdir(), "jsonrpc-built-scope-")); +const ctx = new Context(); +try { + await ctx.plugin(agentCore, { workspaceContext: false }); + await ctx.plugin(SubagentService); + await ctx.plugin(SessionPersistenceJsonl, { root: storageRoot }); + await new Promise((ready) => setTimeout(ready, 50)); + + const notifications = []; + const server = new HarnessSdkServer(ctx, { + request() { return Promise.reject(new Error("unexpected host request")); }, + notify(method, params) { notifications.push({ method, params }); }, + }); + const parent = await ctx.agents.create({ + sessionId: SessionId("built-parent"), + meta: { cwd: storageRoot }, + agentOptions: { model: "test" }, + }); + const child = await parent.agent.ctx.agents.create({ + sessionId: SessionId("built-child"), + meta: { cwd: storageRoot, parentSession: SessionId("built-parent") }, + agentOptions: { model: "test" }, + }); + const result = Promise.withResolvers(); + const unregister = ctx.subagents.registerProvider({ + name: "built-local", + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start() { + return Promise.resolve({ + id: child.agent.id, + localAgent: child.agent, + result: result.promise, + dispose() { return Promise.resolve(); }, + }); + }, + }); + const run = await ctx.subagents.start("built-local", { + parent: parent.agent, + prompt: [], + signal: new AbortController().signal, + }); + await child.dispose(); + result.resolve({ output: [], stopReason: "completed" }); + await run.result; + await Promise.resolve(); + + console.log(JSON.stringify(notifications.filter(({ method }) => method === "subagent.finished"))); + await run.dispose(); + unregister(); + await parent.dispose(); + await server.shutdown(); +} finally { + await ctx.fiber.dispose(); + await rm(storageRoot, { recursive: true, force: true }); +} +` + +describe.skipIf(!existsSync(jsonrpcBundle))('dsh-jsonrpc BUILT scope carrier', () => { + it('preserves parent-scoped completion after child disposal', async () => { + const { stdout, stderr } = await execFileAsync(process.execPath, ['--input-type=module', '-e', builtRuntimeProbe], { + cwd: repoRoot, + timeout: 15_000, + }) + + expect(stderr).not.toContain('listener threw') + expect(JSON.parse(stdout) as unknown).toEqual([{ + method: 'subagent.finished', + params: { + provider: 'built-local', + agentId: 'built-child', + parentSessionId: 'built-parent', + childSessionId: 'built-child', + status: 'ok', + stopReason: 'completed', + lastAssistantMessage: [], + }, + }]) + }) +}) diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 22f01178ec..5b9259dd85 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -5,12 +5,13 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { AgentId, type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent' +import { type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent' + import { SessionId } from '@deepseek-ai/dsh-session' import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' -import SubagentService, { type SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' +import SubagentService, { type SubagentResult, type SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' import { HarnessSdkServer, type JsonRpcTransportPeer } from '../src/index.ts' class FakeTransport implements JsonRpcTransportPeer { @@ -66,7 +67,13 @@ async function makeHarness(storageDir: string) { } /** Drive the owning service so test lifecycle events carry the real parent scope. */ -async function settleSubagent(ctx: Context, parent: Agent, info: SubagentRunEndInfo): Promise { +async function settleSubagent( + ctx: Context, + parent: Agent, + info: Omit & { localAgent: Agent | undefined }, + beforeSettle?: () => Promise, +): Promise { + const result = Promise.withResolvers() const disposeProvider = ctx.subagents.registerProvider({ name: info.provider, capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, @@ -74,9 +81,8 @@ async function settleSubagent(ctx: Context, parent: Agent, info: SubagentRunEndI async start() { return { id: info.id, - result: info.lastAssistantMessage === undefined - ? Promise.reject(new Error('synthetic infrastructure failure')) - : Promise.resolve({ output: info.lastAssistantMessage, stopReason: info.stopReason }), + localAgent: info.localAgent, + result: result.promise, dispose: () => Promise.resolve(), } }, @@ -87,6 +93,12 @@ async function settleSubagent(ctx: Context, parent: Agent, info: SubagentRunEndI prompt: [], signal: new AbortController().signal, }) + await beforeSettle?.() + if (info.lastAssistantMessage === undefined) { + result.reject(new Error('synthetic infrastructure failure')) + } else { + result.resolve({ output: info.lastAssistantMessage, stopReason: info.stopReason }) + } await run.result.then(() => undefined, () => undefined) await run.dispose() } finally { @@ -136,7 +148,6 @@ describe('HarnessSdkServer', () => { expect(llmServer.requests).toHaveLength(2) const orphanHandle = await ctx.agents.create({ - agentId: AgentId('orphan-agent'), sessionId: SessionId('orphan-session'), meta: { cwd: storageDir }, agentOptions: { provider: 'deepseek', model: 'dsagent-model' }, @@ -171,8 +182,8 @@ describe('HarnessSdkServer', () => { } as unknown as Agent const mainHandle = { agent: mainAgent, dispose: vi.fn(() => Promise.resolve()) } const otherHandle = { agent: otherAgent, dispose: vi.fn(() => Promise.resolve()) } - const create = vi.fn(async (options: { agentId: AgentId }) => - String(options.agentId) === 'main' ? mainHandle : otherHandle) + const create = vi.fn(async (options: { sessionId: SessionId }) => + String(options.sessionId) === 'main' ? mainHandle : otherHandle) const ctx = { on: vi.fn(() => () => undefined), agents: { create, get: () => undefined }, @@ -264,29 +275,42 @@ describe('HarnessSdkServer', () => { const server = new HarnessSdkServer(ctx, transport) const parentHandle = await ctx.agents.create({ - agentId: AgentId('parent-agent'), sessionId: SessionId('main'), meta: { cwd: storageDir }, agentOptions: { provider: 'deepseek', model: 'deepseek' }, }) + // A custom in-process provider may own its child at the provider/root + // scope while preserving durable parent lineage. const handle = await ctx.agents.create({ - agentId: AgentId('child-agent'), sessionId: SessionId('child-session'), meta: { cwd: storageDir, parentSession: SessionId('main') }, agentOptions: { provider: 'deepseek', model: 'deepseek' }, }) + expect(ctx.agents.roots()).toContain(handle.agent) + const parentlessHandle = await parentHandle.agent.ctx.agents.create({ + sessionId: SessionId('parentless-child-session'), + meta: { cwd: storageDir }, + agentOptions: { model: 'deepseek' }, + }) await settleSubagent(ctx, parentHandle.agent, { provider: 'spawn', - id: AgentId('child-agent'), + id: SessionId('child-session'), + localAgent: handle.agent, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'child done' }], - }) + }, () => handle.dispose()) + await settleSubagent(ctx, parentHandle.agent, { + provider: 'spawn', + id: SessionId('parentless-child-session'), + localAgent: parentlessHandle.agent, + stopReason: 'error', + }, () => parentlessHandle.dispose()) expect(transport.notifications).toContainEqual({ method: 'subagent.finished', params: { provider: 'spawn', - agentId: 'child-agent', + agentId: 'child-session', parentSessionId: 'main', childSessionId: 'child-session', status: 'ok', @@ -294,8 +318,18 @@ describe('HarnessSdkServer', () => { lastAssistantMessage: [{ type: 'text', text: 'child done' }], }, }) + expect(transport.notifications).toContainEqual({ + method: 'subagent.finished', + params: { + provider: 'spawn', + agentId: 'parentless-child-session', + parentSessionId: 'main', + childSessionId: 'parentless-child-session', + status: 'error', + stopReason: 'error', + }, + }) - await handle.dispose() await parentHandle.dispose() await server.shutdown() } finally { @@ -304,7 +338,282 @@ describe('HarnessSdkServer', () => { } }) - it('falls back to live agent lineage for uncached subagent end events', async () => { + it('ignores a remote run id that collides with a local child of the same parent', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-remote-collision-')) + const ctx = await makeHarness(storageDir) + try { + const transport = new FakeTransport() + const server = new HarnessSdkServer(ctx, transport) + const parentHandle = await ctx.agents.create({ + sessionId: SessionId('collision-parent'), + meta: { cwd: storageDir }, + agentOptions: { model: 'deepseek' }, + }) + const collidingChild = await parentHandle.agent.ctx.agents.create({ + sessionId: SessionId('remote-run-id'), + meta: { cwd: storageDir, parentSession: SessionId('collision-parent') }, + agentOptions: { model: 'deepseek' }, + }) + + await settleSubagent(ctx, parentHandle.agent, { + provider: 'remote', + id: SessionId('remote-run-id'), + localAgent: undefined, + stopReason: 'completed', + lastAssistantMessage: [], + }) + + expect(transport.notifications.some(notification => + notification.method === 'subagent.finished' + && notification.params?.agentId === 'remote-run-id', + )).toBe(false) + + await collidingChild.dispose() + await parentHandle.dispose() + await server.shutdown() + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('retains locality across continuation runs on one live child', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-continuation-')) + const ctx = await makeHarness(storageDir) + try { + const transport = new FakeTransport() + const server = new HarnessSdkServer(ctx, transport) + const parentHandle = await ctx.agents.create({ + sessionId: SessionId('continuation-parent'), + meta: { cwd: storageDir }, + agentOptions: { model: 'deepseek' }, + }) + const childHandle = await parentHandle.agent.ctx.agents.create({ + sessionId: SessionId('continuation-child'), + meta: { cwd: storageDir, parentSession: SessionId('continuation-parent') }, + agentOptions: { model: 'deepseek' }, + }) + + await settleSubagent(ctx, parentHandle.agent, { + provider: 'continuation', + id: SessionId('continuation-child'), + localAgent: childHandle.agent, + stopReason: 'completed', + lastAssistantMessage: [{ type: 'text', text: 'first' }], + }) + await settleSubagent(ctx, parentHandle.agent, { + provider: 'continuation', + id: SessionId('continuation-child'), + localAgent: childHandle.agent, + stopReason: 'completed', + lastAssistantMessage: [{ type: 'text', text: 'second' }], + }, () => childHandle.dispose()) + + expect(transport.notifications.filter(notification => + notification.method === 'subagent.finished' + && notification.params?.childSessionId === 'continuation-child', + )).toHaveLength(2) + + await parentHandle.dispose() + await server.shutdown() + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('correlates reused local ids by parent scope when runs settle out of order', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-reuse-')) + const ctx = await makeHarness(storageDir) + try { + const transport = new FakeTransport() + const server = new HarnessSdkServer(ctx, transport) + const oldParent = await ctx.agents.create({ + sessionId: SessionId('old-parent'), + meta: { cwd: storageDir }, + agentOptions: { model: 'deepseek' }, + }) + const oldChild = await oldParent.agent.ctx.agents.create({ + sessionId: SessionId('reused-child'), + meta: { cwd: storageDir, parentSession: SessionId('old-parent') }, + agentOptions: { model: 'deepseek' }, + }) + const first = Promise.withResolvers() + const sameLifetime = Promise.withResolvers() + const replacement = Promise.withResolvers() + const results = [first.promise, sameLifetime.promise, replacement.promise] + let starts = 0 + let currentLocalAgent = oldChild.agent + const disposeProvider = ctx.subagents.registerProvider({ + name: 'reused', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start() { + const result = results[starts] + starts += 1 + if (result === undefined) throw new Error('unexpected fourth reused-id run') + return Promise.resolve({ id: SessionId('reused-child'), localAgent: currentLocalAgent, result, dispose: () => Promise.resolve() }) + }, + }) + + const firstRun = await ctx.subagents.start('reused', { + parent: oldParent.agent, + prompt: [], + signal: new AbortController().signal, + }) + const sameLifetimeRun = await ctx.subagents.start('reused', { + parent: oldParent.agent, + prompt: [], + signal: new AbortController().signal, + }) + sameLifetime.resolve({ output: [{ type: 'text', text: 'same lifetime' }], stopReason: 'completed' }) + await sameLifetimeRun.result + await oldChild.dispose() + const newParent = await ctx.agents.create({ + sessionId: SessionId('new-parent'), + meta: { cwd: storageDir }, + agentOptions: { model: 'deepseek' }, + }) + const newChild = await newParent.agent.ctx.agents.create({ + sessionId: SessionId('reused-child'), + meta: { cwd: storageDir, parentSession: SessionId('new-parent') }, + agentOptions: { model: 'deepseek' }, + }) + currentLocalAgent = newChild.agent + const secondRun = await ctx.subagents.start('reused', { + parent: newParent.agent, + prompt: [], + signal: new AbortController().signal, + }) + + replacement.resolve({ output: [{ type: 'text', text: 'new lifetime' }], stopReason: 'completed' }) + await secondRun.result + first.resolve({ output: [{ type: 'text', text: 'old lifetime' }], stopReason: 'completed' }) + await firstRun.result + await Promise.resolve() + + const finished = transport.notifications.filter(notification => + notification.method === 'subagent.finished' + && notification.params?.childSessionId === 'reused-child', + ) + expect(finished.map(notification => notification.params?.lastAssistantMessage)).toEqual([ + [{ type: 'text', text: 'same lifetime' }], + [{ type: 'text', text: 'new lifetime' }], + [{ type: 'text', text: 'old lifetime' }], + ]) + expect(finished.map(notification => notification.params?.parentSessionId)).toEqual([ + 'old-parent', + 'new-parent', + 'old-parent', + ]) + + await firstRun.dispose() + await sameLifetimeRun.dispose() + await secondRun.dispose() + disposeProvider() + await newChild.dispose() + await oldParent.dispose() + await newParent.dispose() + await server.shutdown() + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('keeps locality bound to the accepted run across provider re-registration', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-provider-reuse-')) + const ctx = await makeHarness(storageDir) + try { + const transport = new FakeTransport() + const server = new HarnessSdkServer(ctx, transport) + const parent = await ctx.agents.create({ + sessionId: SessionId('provider-reuse-parent'), + meta: { cwd: storageDir }, + agentOptions: { model: 'deepseek' }, + }) + const child = await parent.agent.ctx.agents.create({ + sessionId: SessionId('provider-reuse-child'), + meta: { cwd: storageDir, parentSession: SessionId('provider-reuse-parent') }, + agentOptions: { model: 'deepseek' }, + }) + const localResult = Promise.withResolvers() + const remoteResult = Promise.withResolvers() + const unregisterLocal = ctx.subagents.registerProvider({ + name: 'reused-provider', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: () => Promise.resolve({ + id: SessionId('provider-reuse-child'), + localAgent: child.agent, + result: localResult.promise, + dispose: () => Promise.resolve(), + }), + }) + const localRun = await ctx.subagents.start('reused-provider', { + parent: parent.agent, + prompt: [], + signal: new AbortController().signal, + }) + unregisterLocal() + + const unregisterRemote = ctx.subagents.registerProvider({ + name: 'reused-provider', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: () => Promise.resolve({ + id: SessionId('provider-reuse-child'), + localAgent: undefined, + result: remoteResult.promise, + dispose: () => Promise.resolve(), + }), + }) + const remoteRun = await ctx.subagents.start('reused-provider', { + parent: parent.agent, + prompt: [], + signal: new AbortController().signal, + }) + + remoteResult.resolve({ output: [{ type: 'text', text: 'remote' }], stopReason: 'completed' }) + await remoteRun.result + await Promise.resolve() + expect(transport.notifications.some(notification => + notification.method === 'subagent.finished' + && notification.params?.lastAssistantMessage !== undefined, + )).toBe(false) + + await child.dispose() + localResult.resolve({ output: [{ type: 'text', text: 'local' }], stopReason: 'completed' }) + await localRun.result + await Promise.resolve() + expect(transport.notifications.filter(notification => + notification.method === 'subagent.finished' + && notification.params?.childSessionId === 'provider-reuse-child', + )).toEqual([{ + method: 'subagent.finished', + params: { + provider: 'reused-provider', + agentId: 'provider-reuse-child', + parentSessionId: 'provider-reuse-parent', + childSessionId: 'provider-reuse-child', + status: 'ok', + stopReason: 'completed', + lastAssistantMessage: [{ type: 'text', text: 'local' }], + }, + }]) + + await localRun.dispose() + await remoteRun.dispose() + unregisterRemote() + await parent.dispose() + await server.shutdown() + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('uses explicit local provenance when start was missed and ignores remote runs', async () => { const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-fallback-')) const ctx = await makeHarness(storageDir) let parentHandle: AgentHandle | undefined @@ -312,40 +621,67 @@ describe('HarnessSdkServer', () => { let failedHandle: AgentHandle | undefined try { parentHandle = await ctx.agents.create({ - agentId: AgentId('fallback-parent-agent'), sessionId: SessionId('fallback-parent'), meta: { cwd: storageDir }, agentOptions: { provider: 'deepseek', model: 'deepseek' }, }) - handle = await ctx.agents.create({ - agentId: AgentId('fallback-child-agent'), + handle = await parentHandle.agent.ctx.agents.create({ sessionId: SessionId('fallback-child-session'), meta: { cwd: storageDir, parentSession: SessionId('fallback-parent') }, agentOptions: { provider: 'deepseek', model: 'deepseek' }, }) - failedHandle = await ctx.agents.create({ - agentId: AgentId('failed-child-agent'), + const fallbackChild = handle.agent + failedHandle = await parentHandle.agent.ctx.agents.create({ sessionId: SessionId('failed-child-session'), meta: { cwd: storageDir }, agentOptions: { provider: 'deepseek', model: 'deepseek' }, }) + const missedStartResult = Promise.withResolvers() + const disposeMissedStartProvider = ctx.subagents.registerProvider({ + name: 'fork', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: true, + start: () => Promise.resolve({ + id: SessionId('fallback-child-session'), + localAgent: fallbackChild, + result: missedStartResult.promise, + dispose: () => Promise.resolve(), + }), + }) + // Start before the server subscribes. The terminal payload still carries + // this run's exact local child without reconstructing it from ids. + const missedStartRun = await ctx.subagents.start('fork', { + parent: parentHandle.agent, + prompt: [], + signal: new AbortController().signal, + }) const transport = new FakeTransport() const server = new HarnessSdkServer(ctx, transport) + missedStartResult.resolve({ output: [], stopReason: 'max-tokens' }) + await missedStartRun.result + await Promise.resolve() + await missedStartRun.dispose() + disposeMissedStartProvider() + // The server also missed this agent's creation but sees the exact child + // on the run lifecycle payload. await settleSubagent(ctx, parentHandle.agent, { - provider: 'fork', - id: AgentId('fallback-child-agent'), - stopReason: 'max-tokens', + provider: 'fork-live-fallback', + id: SessionId('fallback-child-session'), + localAgent: fallbackChild, + stopReason: 'completed', lastAssistantMessage: [], }) await settleSubagent(ctx, parentHandle.agent, { provider: 'fork', - id: AgentId('failed-child-agent'), + id: SessionId('failed-child-session'), + localAgent: failedHandle.agent, stopReason: 'error', }) await settleSubagent(ctx, parentHandle.agent, { provider: 'fork', - id: AgentId('missing-child-agent'), + id: SessionId('missing-child-agent'), + localAgent: undefined, stopReason: 'error', }) @@ -353,7 +689,7 @@ describe('HarnessSdkServer', () => { method: 'subagent.finished', params: { provider: 'fork', - agentId: 'fallback-child-agent', + agentId: 'fallback-child-session', parentSessionId: 'fallback-parent', childSessionId: 'fallback-child-session', status: 'error', @@ -365,7 +701,8 @@ describe('HarnessSdkServer', () => { method: 'subagent.finished', params: { provider: 'fork', - agentId: 'failed-child-agent', + agentId: 'failed-child-session', + parentSessionId: 'fallback-parent', childSessionId: 'failed-child-session', status: 'error', stopReason: 'error', @@ -569,6 +906,6 @@ describe('HarnessSdkServer', () => { const server = new HarnessSdkServer(ctx, new FakeTransport()) await expect(server.shutdown()).rejects.toBe(listenerFailure) - expect(on).toHaveBeenCalledTimes(4) + expect(on).toHaveBeenCalledTimes(3) }) }) diff --git a/packages/ui/stdio/README.md b/packages/ui/stdio/README.md index b7d320880d..f9cebe11e6 100644 --- a/packages/ui/stdio/README.md +++ b/packages/ui/stdio/README.md @@ -9,7 +9,7 @@ This package owns the terminal channel only. It injects `agents` and `userIntera | Key | Default | Meaning | |---|---|---| | `welcome` | `ready.` | Banner printed before the first prompt | -| `agent` | `main` | Agent id driven by stdin and observed for EOF shutdown | +| `sessionId` | `main` | Exact agent/session identity driven by stdin and observed for EOF shutdown | The plugin seeds display labels from the live agent registry, then tracks `agent/created` and `agent/disposed` so HMR and externally managed agents render consistently. Disposal closes readline and unregisters every listener/provider through Cordis effects. @@ -18,7 +18,7 @@ The plugin seeds display labels from the live agent registry, then tracks `agent name: '@deepseek-ai/dsh-stdio' config: welcome: 'agent REPL ready. Give it a coding task.' - agent: main + sessionId: main ``` ## Model Experience @@ -37,6 +37,6 @@ The plugin seeds display labels from the live agent registry, then tracks `agent ## Known Limitations and Deferred Work -- **One configured agent receives stdin** — the session/event renderer can print output from any session, but input lines always drive the configured `agent` id rather than routing by the visible label. +- **One configured session receives stdin** — the session/event renderer can print output from any session, but input lines always drive the configured `sessionId` rather than routing by the visible label. - **Terminal questions are text-only and sequential** — the provider queues asks, supports option labels plus custom text, and has no richer UI shapes such as file pickers or diff previews. - **Closed stdin ends the terminal channel** — EOF rejects active or queued questions and exits after submitted work reaches idle; there is no reconnect path for a long-lived process. diff --git a/packages/ui/stdio/package.json b/packages/ui/stdio/package.json index 3b00dc6625..e1bffdf171 100644 --- a/packages/ui/stdio/package.json +++ b/packages/ui/stdio/package.json @@ -23,10 +23,16 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-agent-loop": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" + }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-agent-loop": { + "optional": true + } }, "dependencies": { "schemastery": "^3.18.0" @@ -34,9 +40,10 @@ "devDependencies": { "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/ui/stdio/src/index.ts b/packages/ui/stdio/src/index.ts index 1e665381ce..6f73d948bf 100644 --- a/packages/ui/stdio/src/index.ts +++ b/packages/ui/stdio/src/index.ts @@ -1,7 +1,8 @@ /** * The stdio app's readline UI: reads lines from stdin into `agent.send()` or - * `steer()`, renders the durable event stream to stdout, and exits piped input - * only after submitted work reaches idle. + * `steer()`, renders the durable event stream to stdout, buffers startup input + * for one exact agent/session identity, and exits piped input only after + * submitted work reaches idle. * * This package is the independently composable stdio front door. It establishes * the terminal channel and drives an agent created or resumed by app or @@ -13,7 +14,9 @@ import { createInterface } from 'node:readline' import type { Readable, Writable } from 'node:stream' import type { Context } from 'cordis' import z from 'schemastery' -import { AgentId } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-agent-loop' +import { SessionId } from '@deepseek-ai/dsh-session' import { UserInteractionError, type AskUserQuestionAnswer, @@ -30,13 +33,13 @@ export const inject = ['agents', 'userInteraction'] export interface Config { /** Banner printed once on start, before the first `> ` prompt. */ welcome?: string - /** Id of the agent stdin drives (`send`/`steer`) and whose status gates the EOF exit; rendering is global. Defaults to `'main'`. */ - agent?: string + /** Exact shared agent/session identity stdin drives. Defaults to `'main'`. */ + sessionId?: string } export const Config: z = z.object({ welcome: z.string().default('ready.'), - agent: z.string().default('main'), + sessionId: z.string().default('main'), }) /** @@ -59,6 +62,15 @@ function isTTYPair(input: Readable, output: Writable): boolean { return Boolean((input as { isTTY?: boolean }).isTTY && (output as { isTTY?: boolean }).isTTY) } +/** Render an arbitrary failure without allowing hostile coercion to escape the UI boundary. */ +function renderThrown(value: unknown): string { + try { + return String(value) + } catch { + return '' + } +} + interface PendingQuestion { request: AskUserQuestionRequest questionIndex: number @@ -74,10 +86,15 @@ type OptionSelection = | { kind: 'invalid' } /** - * Register stdio chat against an injectable I/O runtime. - * @param ctx - agent and event context. - * @param config - plugin config, defaulted for direct callers. - * @param runtime - line source, render sink, and exit hook. + * The plugin body, parameterized over its I/O runtime. `apply` is the thin + * production wrapper that binds the real `process` streams; tests call this + * directly with fakes. Returns nothing — all registration is via `ctx.on`/ + * `ctx.effect`, so fiber disposal tears every listener and the readline + * interface down. + * @param ctx - the context supplying the `agents` service and the event feeds. + * @param config - the plugin config; defaults are re-applied here for direct + * callers that bypass Loader validation. + * @param runtime - the process-I/O seam (line source, render sink, exit hook). */ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRuntime): void { // Default here too (not just via schemastery's `.default()`): this helper is @@ -85,18 +102,22 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt // Loader validation, so it must be self-contained rather than trusting the // cast — `config.welcome as string` would otherwise be `undefined` on `{}`. const welcome = config.welcome ?? 'ready.' - const agentId = AgentId(config.agent ?? 'main') + const sessionId = SessionId(config.sessionId ?? 'main') const { input, output, exit } = runtime - // Session ids need not equal agent ids. Seed existing agents before listening - // so a pre-created or HMR-surviving agent still gets its short render label. - const labelBySession = new Map() - for (const agent of ctx.agents.list()) labelBySession.set(agent.session.header.id, agent.id) - ctx.on('agent/created', (agent) => { labelBySession.set(agent.session.header.id, agent.id) }) - ctx.on('agent/disposed', (agent) => { labelBySession.delete(agent.session.header.id) }) + // Bind only to the exact identity this app passed to its config-created + // agent. Session ids are opaque: neither a prefix nor registry order can + // identify ownership. The root check rejects a child that somehow preempts + // the configured id; later recreation under the same id supports loop HMR. + const matchesConfiguredIdentity = (agent: Agent): boolean => + agent.id === sessionId && ctx.agents.roots().includes(agent) + let target: Agent | undefined = ctx.agents.roots().find(agent => agent.id === sessionId) - // Render the canonical append order from session/event so reasoning state is - // deterministic across chunks and boundaries; there are no agent/* mirrors. + // Transcript rendering off the durable `session/event` feed — the assistant + // token stream, turn/step boundaries, tool activity, and todos all come from + // the one canonical stream (no agent/* mirrors). A single listener over the + // append order keeps `inReasoning` transitions deterministic across chunk and + // boundary events. let inReasoning = false ctx.on('session/event', (session, event) => { if (event.type === 'assistant/chunk') { @@ -112,7 +133,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt output.write(chunk.text) } } else if (event.type === 'turn/start') { - const label = labelBySession.get(session.header.id) ?? session.header.id + const label = target?.session === session ? 'main' : session.id output.write(`\n[${label} turn ${event.data.turn}] `) } else if (event.type === 'turn/end') { if (inReasoning) output.write('\x1B[0m') @@ -138,10 +159,16 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt }) ctx.effect(() => { - const reader = createInterface({ input, output, terminal: isTTYPair(input, output) }) - // On piped EOF, exit immediately if no work was submitted. Otherwise wait - // for a real running state followed by idle: sends do not synchronously mark - // running, and several queued lines may share one turn. + // Piped-input exit, once stdin reaches EOF: + // - If no line ever submitted work (empty stdin, blank-only lines), exit + // immediately — no turn will ever start, so there is nothing to wait + // for. (Gating on an observed 'running' here would hang forever.) + // - If work WAS submitted, exit the next time the agent settles to idle + // AFTER having run. Two subtleties this handles: the loop batches + // several queued messages into ONE turn (one idle), so we don't count + // sends; and agent.send() does NOT synchronously flip status to + // 'running', so requiring an observed 'running' first (`sawRunning`) + // avoids exiting in the gap before the turn starts and dropping work. let stdinClosed = false let disposed = false let submittedWork = false @@ -149,6 +176,38 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt let exitTimer: ReturnType | undefined let activeQuestion: PendingQuestion | undefined const questionQueue: PendingQuestion[] = [] + const queuedInput: string[] = [] + let targetReady = target !== undefined + let hadReadyTarget = targetReady + let failedStartup: { error: unknown } | undefined + + const submit = (agent: Agent, text: string): void => { + submittedWork = true + if (agent.status === 'running') { + agent.steer([{ type: 'text', text }]) + } else { + agent.send([{ type: 'text', text }]) + } + } + + const disposeCreatedListener = ctx.on('agent/created', (agent) => { + if (!matchesConfiguredIdentity(agent)) return + target = agent + targetReady = false + failedStartup = undefined + }) + const disposeSessionStartListener = ctx.on('agent/session-start', (agent) => { + if (agent !== target) return + targetReady = true + hadReadyTarget = true + for (const text of queuedInput.splice(0)) submit(agent, text) + }) + const disposeDisposedListener = ctx.on('agent/disposed', (agent) => { + if (target !== agent) return + target = undefined + targetReady = false + }) + const reader = createInterface({ input, output, terminal: isTTYPair(input, output) }) const maybeExit = (): void => { if (disposed || !stdinClosed) return @@ -156,19 +215,33 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt // Work submitted: wait until a turn has run and the agent is idle. if (submittedWork) { if (!sawRunning) return - const agent = ctx.agents.get(agentId) + const agent = target if (agent && agent.status !== 'idle') return // a turn is still running } - // Let final output flush; track the timer so re-entry coalesces and HMR - // disposal can cancel it before it exits the replacement process. + // Let any final output flush, then exit. The handle is tracked so the + // disposer can cancel it — a dispose within the flush window must not let + // the process exit out from under HMR. Re-entrant `maybeExit` calls (e.g. + // repeated idle signals) coalesce onto the one pending timer. if (exitTimer !== undefined) { return // exit already scheduled — coalesce re-entrant calls } exitTimer = setTimeout(() => { exit(0) }, 200) } + const disposeStartupFailedListener = ctx.on('agent-loop/config-start-failed', (failedSessionId, error) => { + if (failedSessionId !== sessionId || targetReady) return + failedStartup = { error } + const dropped = queuedInput.length + queuedInput.length = 0 + submittedWork = sawRunning + if (dropped > 0) { + ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (${dropped} line(s)): ${renderThrown(error)}`) + } + maybeExit() + }) + const disposeStatusListener = ctx.on('agent/status', (subject, status) => { - if (subject.id !== agentId) return + if (subject !== target) return if (status === 'running') sawRunning = true if (status === 'idle') maybeExit() }) @@ -321,17 +394,25 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt } const text = line.trim() if (!text) return - const agent = ctx.agents.get(agentId) - if (!agent) { - ctx.logger.error('ui-stdio: agent "%s" is not running', agentId) + if (failedStartup !== undefined) { + ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): ${renderThrown(failedStartup.error)}`) return } - submittedWork = true - if (agent.status === 'running') { - agent.steer([{ type: 'text', text }]) - } else { - agent.send([{ type: 'text', text }]) + const agent = target + if (agent === undefined || !targetReady) { + // Initial exact-id restoration is asynchronous. Preserve input until + // session-start, the first supported point for queueing agent work. + // After a previously ready target disappears, a line in the HMR gap + // still fails loud unless its exact replacement is already publishing. + if (!hadReadyTarget || agent !== undefined) { + submittedWork = true + queuedInput.push(text) + return + } + ctx.logger.error('ui-stdio: main agent is not running') + return } + submit(agent, text) }) reader.on('close', () => { // Fires for BOTH stdin EOF and plugin disposal (reader.close() below); @@ -347,31 +428,25 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt disposePendingQuestions() disposeUserInteractionProvider() disposeStatusListener() + disposeCreatedListener() + disposeSessionStartListener() + disposeDisposedListener() + disposeStartupFailedListener() reader.close() } }, 'ui-stdio') } /** - * Open the terminal channel once its configured agent exists. Generated stdio - * projects boot the Cordis tree first and create or resume the agent from - * developer code immediately afterward, so stdin must remain untouched until - * the matching `agent/created` notification arrives. + * Open the terminal channel for one exact identity. The chat registers before + * that agent necessarily exists so it can buffer startup input and observe a + * config-start failure instead of leaving piped stdin hanging. * @param ctx - the context supplying the agent registry and event stream. * @param config - presentation and target-agent configuration. * @param runtime - process-I/O seam. */ export function mountStdio(ctx: Context, config: Config, runtime: StdioRuntime): void { - const agentId = AgentId(config.agent ?? 'main') - if (ctx.agents.get(agentId) !== undefined) { - createStdioChat(ctx, config, runtime) - return - } - const dispose = ctx.on('agent/created', (agent) => { - if (agent.id !== agentId) return - dispose() - createStdioChat(ctx, config, runtime) - }) + createStdioChat(ctx, config, runtime) } /** diff --git a/packages/ui/stdio/tests/readline.spec.ts b/packages/ui/stdio/tests/readline.spec.ts index 638e98bf59..6a97eab06a 100644 --- a/packages/ui/stdio/tests/readline.spec.ts +++ b/packages/ui/stdio/tests/readline.spec.ts @@ -16,9 +16,9 @@ function fakeContext(): Context { return { on: vi.fn(() => vi.fn()), effect: vi.fn((callback: () => () => void) => callback()), - // The UI seeds its label map from the registry at install; this suite only + // The UI seeds its root target from the registry at install; this suite only // exercises readline terminal-mode selection, so an empty roster suffices. - agents: { list: vi.fn(() => []) }, + agents: { roots: vi.fn(() => []) }, userInteraction: { registerProvider: vi.fn(() => vi.fn()) }, } as unknown as Context } diff --git a/packages/ui/stdio/tests/stdio.spec.ts b/packages/ui/stdio/tests/stdio.spec.ts index 7bb6a6f245..a3069462ff 100644 --- a/packages/ui/stdio/tests/stdio.spec.ts +++ b/packages/ui/stdio/tests/stdio.spec.ts @@ -4,7 +4,7 @@ import { Context } from 'cordis' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import { createStdioChat, mountStdio, type Config, type StdioRuntime } from '../src/index.ts' @@ -57,17 +57,23 @@ function makeAgent(id: string, status: AgentStatus = 'idle'): Agent & { status, sent, steered, - // A minimal session stub: the UI reads only `session.header.id` (to map the - // session back to its agent id for the turn-boundary label). - session: { header: { id: `${id}-session` } }, + // A minimal session stub with the agent's shared durable identity. + session: { id, header: { id } }, send: (content: ContentBlock[]) => void sent.push(content), steer: (content: ContentBlock[]) => void steered.push(content), } as never } +/** Register a fake configured agent and cross the supported startup-work boundary. */ +function registerReady(ctx: Context, agent: Agent, source: 'startup' | 'resume' = 'startup'): () => void { + const dispose = ctx.agents.register(agent) + ctx.emit('agent/session-start', agent, source) + return dispose +} + /** A session stub whose `header.id` matches an agent's, for `session/event` emits. */ -function makeSession(agentId: string): Session { - return { header: { id: `${agentId}-session` } } as Session +function makeSession(id: string): Session { + return { id, header: { id } } as Session } /** An `assistant/chunk` session event carrying one raw stream chunk. */ @@ -75,7 +81,11 @@ function chunkEvent(chunk: StreamChunk): SessionEvent { return { type: 'assistant/chunk', seq: 0, time: 0, data: { turn: 1, step: 0, chunk } } } -const CONFIG: Config = { welcome: 'hi there', agent: 'main' } +const CONFIG: Config = { welcome: 'hi there', sessionId: 'main' } + +function unrenderableFailure(): unknown { + return { [Symbol.toPrimitive](): never { throw new Error('coercion escaped') } } +} async function setup(config: Config = CONFIG, runtimeOver: Partial = {}) { const ctx = new Context() @@ -94,7 +104,7 @@ function flushExit(): Promise { } describe('mountStdio readiness', () => { - it('leaves stdin untouched until the configured agent is created', async () => { + it('opens before the configured agent is created so startup input can queue', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) @@ -103,9 +113,9 @@ describe('mountStdio readiness', () => { mountStdio(inner, CONFIG, runtime) }, { inject: ['agents', 'userInteraction'] })) - expect(out.text()).toBe('') + expect(out.text()).toBe('hi there\n> ') ctx.agents.register(makeAgent('other')) - expect(out.text()).toBe('') + expect(out.text()).toBe('hi there\n> ') ctx.agents.register(makeAgent('main')) expect(out.text()).toBe('hi there\n> ') await fiber.dispose() @@ -125,7 +135,7 @@ describe('mountStdio readiness', () => { await fiber.dispose() }) - it('waits for main when no target agent is configured', async () => { + it('opens for the default main identity when no target is configured', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) @@ -134,8 +144,9 @@ describe('mountStdio readiness', () => { mountStdio(inner, { welcome: 'ready' }, runtime) }, { inject: ['agents', 'userInteraction'] })) + expect(out.text()).toBe('ready\n> ') ctx.agents.register(makeAgent('other')) - expect(out.text()).toBe('') + expect(out.text()).toBe('ready\n> ') ctx.agents.register(makeAgent('main')) expect(out.text()).toBe('ready\n> ') await fiber.dispose() @@ -148,12 +159,11 @@ describe('createStdioChat rendering', () => { expect(out.text()).toBe('hi there\n> ') }) - it('falls back to default welcome/agent when called with empty config', async () => { + it('falls back to the default welcome when called with empty config', async () => { // createStdioChat is exported and may be driven directly (bypassing the - // Loader's schemastery validation), so it must default welcome/agent itself. + // Loader's schemastery validation), so it must default the welcome itself. const { out } = await setup({}) expect(out.text()).toBe('ready.\n> ') - // And it drives the default agent id 'main'. }) it('detects readline terminal mode from both stream TTY flags', async () => { @@ -205,9 +215,8 @@ describe('createStdioChat rendering', () => { it('renders turn/start and turn/end markers from the session feed', async () => { const { ctx, out } = await setup() const agent = makeAgent('main') - // agent/created populates the session-id → agent-id label map. - ctx.emit('agent/created', agent) - const session = makeSession('main') + ctx.agents.register(agent) + const session = agent.session ctx.emit('session/event', session, { type: 'turn/start', seq: 1, time: 0, data: { turn: 3, trigger: { kind: 'message' } }, } as SessionEvent) @@ -218,35 +227,59 @@ describe('createStdioChat rendering', () => { expect(out.text()).toContain('\n> ') }) - it('falls back to the session id as the label when no agent is mapped', async () => { + it('uses the session id as the label for a non-target session', async () => { const { ctx, out } = await setup() - // No agent/created emitted, so the label map is empty — the header id shows. + // No target exists, so the event's durable identity is the label. ctx.emit('session/event', makeSession('orphan'), { type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } }, } as SessionEvent) - expect(out.text()).toContain('[orphan-session turn 1] ') + expect(out.text()).toContain('[orphan turn 1] ') }) - it('seeds labels for agents already registered before the UI installs', async () => { - // The pre-created `main` agent (and any agent surviving an HMR reload of just this fiber) - // fired its `agent/created` before the UI's listener existed, so the live listener alone - // would miss it. Seeding from `ctx.agents.list()` preserves the `[main turn N]` label instead - // of falling back to the raw session id. + it('uses an agent already registered before the UI installs as its target', async () => { + // The pre-created `main` agent (and any agent surviving an HMR reload of just + // this fiber) fired its `agent/created` before the UI's listener existed, so + // the live listener alone would miss it. Seeding from `ctx.agents.list()` at + // install time preserves the terminal's fixed `[main turn N]` label. const ctx = new Context() await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) const agent = makeAgent('main') + // Durable lineage does not imply runtime child ownership: the stdio app + // may explicitly resume a persisted fork as its one configured agent. + ;(agent.session.header as { parentSession?: string }).parentSession = 'persisted-parent' ctx.agents.register(agent) // registered BEFORE the UI plugin below const { runtime, out } = makeRuntime() await ctx.plugin(Object.assign((inner: Context) => { createStdioChat(inner, CONFIG, runtime) }, { inject: ['agents', 'userInteraction'] })) - ctx.emit('session/event', makeSession('main'), { + ctx.emit('session/event', agent.session, { type: 'turn/start', seq: 1, time: 0, data: { turn: 5, trigger: { kind: 'message' } }, } as SessionEvent) expect(out.text()).toContain('[main turn 5] ') }) + it('buffers input for a lineage-bearing configured agent until its session starts', async () => { + const { ctx, input } = await setup({ welcome: 'hi there', sessionId: 'resumed' }) + input.feed('continue') + await new Promise(resolve => setImmediate(resolve)) + + const unrelated = makeAgent('unrelated') + ctx.agents.register(unrelated) + ctx.emit('agent/session-start', unrelated, 'startup') + const resumed = makeAgent('resumed') + ;(resumed.session.header as { parentSession?: string }).parentSession = 'persisted-parent' + ctx.agents.register(resumed) + await new Promise(resolve => setImmediate(resolve)) + expect(resumed.sent).toEqual([]) + + ctx.emit('agent/session-start', resumed, 'resume') + await new Promise(resolve => setImmediate(resolve)) + + expect(unrelated.sent).toEqual([]) + expect(resumed.sent).toEqual([[{ type: 'text', text: 'continue' }]]) + }) + it('resets dim styling at turn/end if a turn ends mid-reasoning', async () => { const { ctx, out } = await setup() const session = makeSession('main') @@ -257,17 +290,63 @@ describe('createStdioChat rendering', () => { expect(out.text()).toContain('\x1B[2mmid\x1B[0m') }) - it('drops the label mapping on agent/disposed', async () => { + it('drops the target object on agent/disposed', async () => { const { ctx, out } = await setup() const agent = makeAgent('main') - ctx.emit('agent/created', agent) - ctx.emit('agent/disposed', agent) - // After disposal the map no longer resolves the agent id — fall back to the - // session header id. - ctx.emit('session/event', makeSession('main'), { + const dispose = ctx.agents.register(agent) + dispose() + // After disposal the event belongs to a non-target session, so its durable + // identity is rendered directly. + ctx.emit('session/event', agent.session, { type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } }, } as SessionEvent) - expect(out.text()).toContain('[main-session turn 1] ') + expect(out.text()).toContain('[main turn 1] ') + }) + + it('keeps the target when a different agent is disposed', async () => { + const { ctx, out } = await setup() + const target = makeAgent('main') + ctx.agents.register(target) + ctx.emit('agent/disposed', makeAgent('other')) + ctx.emit('session/event', target.session, { + type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } }, + } as SessionEvent) + expect(out.text()).toContain('[main turn 1] ') + }) + + it('retargets only the exact identity after loop HMR recreation', async () => { + const { ctx, input } = await setup({ welcome: 'hi there', sessionId: 'main-session-fixed' }) + const oldRoot = makeAgent('main-session-fixed') + const prefixCollision = makeAgent('main-session-unrelated') + const disposeOld = ctx.agents.register(oldRoot) + ctx.agents.register(prefixCollision) + disposeOld() + const replacement = makeAgent('main-session-fixed') + ctx.agents.register(replacement) + input.feed('after hmr') + await new Promise(resolve => setImmediate(resolve)) + expect(replacement.sent).toEqual([]) + ctx.emit('agent/session-start', replacement, 'resume') + await new Promise(resolve => setImmediate(resolve)) + + expect(prefixCollision.sent).toEqual([]) + expect(replacement.sent).toEqual([[{ type: 'text', text: 'after hmr' }]]) + }) + + it('does not retarget stdin to an unrelated root after the configured agent is disposed', async () => { + const { ctx, input } = await setup() + const unrelated = makeAgent('unrelated') + ctx.agents.register(unrelated) + const configured = makeAgent('main') + const disposeConfigured = registerReady(ctx, configured) + const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) + + disposeConfigured() + input.feed('must not leak') + await new Promise(resolve => setImmediate(resolve)) + + expect(unrelated.sent).toEqual([]) + expect(error).toHaveBeenCalledWith('ui-stdio: main agent is not running') }) it('renders tool/call and tool/result session events', async () => { @@ -683,7 +762,7 @@ describe('createStdioChat input', () => { it('sends a typed line to an idle agent', async () => { const { ctx, input } = await setup() const agent = makeAgent('main', 'idle') - ctx.agents.register(agent) + registerReady(ctx, agent) input.feed('do a thing') await new Promise(r => setImmediate(r)) expect(agent.sent).toEqual([[{ type: 'text', text: 'do a thing' }]]) @@ -693,7 +772,7 @@ describe('createStdioChat input', () => { it('steers a typed line into a running agent', async () => { const { ctx, input } = await setup() const agent = makeAgent('main', 'running') - ctx.agents.register(agent) + registerReady(ctx, agent) input.feed('steer me') await new Promise(r => setImmediate(r)) expect(agent.steered).toEqual([[{ type: 'text', text: 'steer me' }]]) @@ -709,22 +788,57 @@ describe('createStdioChat input', () => { expect(agent.sent).toEqual([]) }) - it('logs and drops a line when the target agent is not running', async () => { + it('buffers a line until the initial target session starts', async () => { const { ctx, input } = await setup() const spy = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) input.feed('nobody home') await new Promise(r => setImmediate(r)) - expect(spy).toHaveBeenCalledWith('ui-stdio: agent "%s" is not running', 'main') + expect(spy).not.toHaveBeenCalled() + + const agent = makeAgent('main') + ctx.agents.register(agent) + await new Promise(r => setImmediate(r)) + expect(agent.sent).toEqual([]) + ctx.emit('agent/session-start', agent, 'startup') + await new Promise(r => setImmediate(r)) + expect(agent.sent).toEqual([[{ type: 'text', text: 'nobody home' }]]) }) - it('drives the agent named in config, not a hardcoded id', async () => { - const { ctx, input } = await setup({ welcome: 'w', agent: 'worker' }) + it('drops later input after the configured startup fails', async () => { + const { ctx, input } = await setup() + const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) + const failure = unrenderableFailure() + ctx.emit('agent-loop/config-start-failed', SessionId('main'), failure) + + input.feed('cannot run') + await new Promise(r => setImmediate(r)) + + expect(error).toHaveBeenCalledWith( + 'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): ', + ) + }) + + it('ignores a stale config-start failure after the exact target is ready', async () => { + const { ctx, input } = await setup() + const agent = makeAgent('main') + registerReady(ctx, agent) + ctx.emit('agent-loop/config-start-failed', SessionId('main'), new Error('stale')) + + input.feed('still live') + await new Promise(r => setImmediate(r)) + + expect(agent.sent).toEqual([[{ type: 'text', text: 'still live' }]]) + }) + + it('drives the exact app-configured resumed session', async () => { + const { ctx, input } = await setup({ welcome: 'w', sessionId: 'worker' }) const agent = makeAgent('worker') - ctx.agents.register(agent) + registerReady(ctx, agent, 'resume') input.feed('hi') await new Promise(r => setImmediate(r)) expect(agent.sent).toHaveLength(1) }) + }) describe('createStdioChat EOF exit', () => { @@ -738,7 +852,7 @@ describe('createStdioChat EOF exit', () => { it('waits for the agent to settle idle after running before exiting', async () => { const { ctx, input, exit } = await setup() const agent = makeAgent('main', 'idle') - ctx.agents.register(agent) + registerReady(ctx, agent) input.feed('work') await new Promise(r => setImmediate(r)) input.finish() @@ -753,10 +867,50 @@ describe('createStdioChat EOF exit', () => { expect(exit).toHaveBeenCalledWith(0) }) + it('keeps piped EOF pending until buffered startup input runs', async () => { + const { ctx, input, exit } = await setup() + input.feed('work') + input.finish() + await flushExit() + expect(exit).not.toHaveBeenCalled() + + const agent = makeAgent('main', 'idle') + ctx.agents.register(agent) + await new Promise(r => setImmediate(r)) + expect(agent.sent).toEqual([]) + ctx.emit('agent/session-start', agent, 'startup') + await new Promise(r => setImmediate(r)) + expect(agent.sent).toEqual([[{ type: 'text', text: 'work' }]]) + ctx.emit('agent/status', agent, 'running') + ;(agent as { status: AgentStatus }).status = 'idle' + ctx.emit('agent/status', agent, 'idle') + await flushExit() + expect(exit).toHaveBeenCalledWith(0) + }) + + it('drains buffered piped input and exits when configured startup fails', async () => { + const { ctx, input, exit } = await setup() + const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) + input.feed('work') + input.finish() + await new Promise(r => setImmediate(r)) + ctx.emit('agent-loop/config-start-failed', SessionId('other'), new Error('unrelated')) + await flushExit() + expect(exit).not.toHaveBeenCalled() + + ctx.emit('agent-loop/config-start-failed', SessionId('main'), unrenderableFailure()) + await flushExit() + + expect(error).toHaveBeenCalledWith( + 'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): ', + ) + expect(exit).toHaveBeenCalledWith(0) + }) + it('schedules the exit only once when idle fires repeatedly', async () => { const { ctx, input, exit } = await setup() const agent = makeAgent('main', 'running') - ctx.agents.register(agent) + registerReady(ctx, agent) input.feed('work') await new Promise(r => setImmediate(r)) ctx.emit('agent/status', agent, 'running') // sawRunning = true @@ -774,7 +928,7 @@ describe('createStdioChat EOF exit', () => { it('does not exit on an idle transition for a different agent', async () => { const { ctx, input, exit } = await setup() const agent = makeAgent('main', 'idle') - ctx.agents.register(agent) + registerReady(ctx, agent) input.feed('work') await new Promise(r => setImmediate(r)) input.finish() @@ -788,7 +942,7 @@ describe('createStdioChat EOF exit', () => { it('does not exit while a turn is still running at EOF', async () => { const { ctx, input, exit } = await setup() const agent = makeAgent('main', 'idle') - ctx.agents.register(agent) + registerReady(ctx, agent) input.feed('work') await new Promise(r => setImmediate(r)) ctx.emit('agent/status', agent, 'running') @@ -837,7 +991,7 @@ describe('createStdioChat disposal (HMR safety)', () => { it('removes the agent/status listener on dispose', async () => { const { ctx, fiber, input, exit } = await setup() const agent = makeAgent('main', 'idle') - ctx.agents.register(agent) + registerReady(ctx, agent) input.feed('work') await new Promise(r => setImmediate(r)) await fiber.dispose() diff --git a/packages/ui/stdio/tsconfig.json b/packages/ui/stdio/tsconfig.json index 00cb815a75..e0c578ed32 100644 --- a/packages/ui/stdio/tsconfig.json +++ b/packages/ui/stdio/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../core/agent" }, + { + "path": "../../core/agent-loop" + }, { "path": "../../core/session" }, diff --git a/packages/util/brand/README.md b/packages/util/brand/README.md index 46e6c1ff98..e292f0bd26 100644 --- a/packages/util/brand/README.md +++ b/packages/util/brand/README.md @@ -4,7 +4,7 @@ The `Branded` nominal-typing primitive — a tiny, **type-only** package (no ## What `Branded` is -A brand makes structurally-identical strings non-interchangeable at the type level: an `AgentId` cannot be passed where a `CallId` is expected, even though both are plain `string`s at runtime. +A brand makes structurally-identical strings non-interchangeable at the type level: a `SessionId` cannot be passed where a `CallId` is expected, even though both are plain `string`s at runtime. ```ts import type { Branded } from '@deepseek-ai/dsh-brand' @@ -21,6 +21,6 @@ Construction goes through the per-id factory in the owning package. Comparison, ## Policy: brand ids that cross package boundaries -A package brands the ids it owns — `CallId` in `dsh-llm`, `SessionId` in `dsh-session`, `AgentId` in `dsh-agent`, and `TaskId` in `dsh-tasks`. Brand cross-package ids that could plausibly be confused; not every string needs one. +A package brands the ids it owns — `CallId` in `dsh-llm`, the shared agent/session `SessionId` in `dsh-session`, and `TaskId` in `dsh-tasks`. Brand cross-package ids that could plausibly be confused; not every string needs one. This package owns only the primitive. Keeping it dependency-free lets `dsh-tasks`, for example, brand `TaskId` without importing an unrelated capability package merely to reach `Branded`. diff --git a/packages/util/brand/src/index.ts b/packages/util/brand/src/index.ts index 0c669e2416..c95cfd6445 100644 --- a/packages/util/brand/src/index.ts +++ b/packages/util/brand/src/index.ts @@ -4,13 +4,13 @@ * cross-boundary id. * * A brand makes structurally-identical strings non-interchangeable at the type - * level: an `AgentId` cannot be passed where a `CallId` is expected, even + * level: a `SessionId` cannot be passed where a `CallId` is expected, even * though both are plain strings at runtime. Construction goes through a per-id * factory in the OWNING package (a plain cast inside — zero runtime cost); * comparison, logging, and serialization all behave as ordinary strings. * * Policy: a package brands the ids it owns — `CallId` in dsh-llm (tool-call - * correlation), `SessionId` in dsh-session, `AgentId` in dsh-agent, and + * correlation), the shared agent/session `SessionId` in dsh-session, and * `TaskId` in dsh-tasks. Branding is for ids that cross package boundaries and * could plausibly be confused; not every string needs a brand. * This package owns ONLY the primitive — no concrete id, no runtime code beyond diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index 5caffca5ac..08bc8171c3 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -4,7 +4,6 @@ import Loader from '@cordisjs/plugin-loader' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' -import { AgentId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { WorkflowRunId, WorkflowService } from '@deepseek-ai/dsh-workflow' import type { WorkflowResult, WorkflowRun, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow' @@ -12,6 +11,7 @@ import { CallId } from '@deepseek-ai/dsh-llm' import SubagentService from '@deepseek-ai/dsh-subagent' import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread' import * as toolWorkflow from '../src/index.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** A controllable engine standing in behind ctx.workflows (the tool's only seam). */ class StubEngine extends WorkflowService { @@ -51,7 +51,7 @@ async function setup(config?: { toolName?: string; maxResultChars?: number }) { await ctx.plugin(StubEngine) await ctx.plugin(toolWorkflow, config ?? {}) const engine = ctx.workflows as StubEngine - const parent = { id: AgentId('caller'), options: {} } as unknown as Agent + const parent = { id: SessionId('caller'), options: {} } as unknown as Agent return { ctx, engine, parent } } @@ -232,7 +232,7 @@ describe('dsh-tool-workflow', () => { await ctx.plugin(SubagentService) await ctx.plugin(WorkerWorkflowEngine, { disposeGraceMs: 30 }) await ctx.plugin(toolWorkflow, {}) - const parent = { id: AgentId('caller'), options: {} } as unknown as Agent + const parent = { id: SessionId('caller'), options: {} } as unknown as Agent const controller = new AbortController() const pending = execute(ctx, { script: 'await new Promise(() => {})\nreturn 1', diff --git a/packages/workflow/workflow-workerthread/src/runtime.ts b/packages/workflow/workflow-workerthread/src/runtime.ts index b5dbed1744..d82eae699d 100644 --- a/packages/workflow/workflow-workerthread/src/runtime.ts +++ b/packages/workflow/workflow-workerthread/src/runtime.ts @@ -13,8 +13,8 @@ */ import * as vm from 'node:vm' -import { AgentId } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' import { assertSupportedOutputSchema, OutputSchemaError } from '@deepseek-ai/dsh-tools' import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools' import { isFatalWorkflowError, WorkflowError } from '@deepseek-ai/dsh-workflow' @@ -295,7 +295,7 @@ export class WorkflowExecution { await run.dispose() throw this.cancelledError() } - const info: WorkflowAgentInfo = { seq, label, ...phase !== undefined ? { phase } : {}, childId: AgentId(run.id) } + const info: WorkflowAgentInfo = { seq, label, ...phase !== undefined ? { phase } : {}, childId: SessionId(run.id) } this.observer.agentStart(info) try { let result diff --git a/packages/workflow/workflow-workerthread/tests/integration.spec.ts b/packages/workflow/workflow-workerthread/tests/integration.spec.ts index 25aa76cf48..320a5322c7 100644 --- a/packages/workflow/workflow-workerthread/tests/integration.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/integration.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as Invariants from '@deepseek-ai/dsh-invariants' @@ -30,7 +30,7 @@ async function setup(script: Script) { await ctx.plugin(spawn, { providerName: 'spawn' }) await ctx.plugin(WorkerWorkflowEngine, {}) ctx.llm.registerAdapter(['mock'], adapter) - const parent = ctx.agentLoop.create(AgentId('parent'), { provider: 'mock', model: 'mock' }) + const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) return { ctx, parent, adapter } } @@ -66,7 +66,7 @@ return { prose, verdict: judged.verdict, confidence: judged.confidence }`, // Both children were disposed to quiescence — no live child agents remain. expect(childIds.length).toBe(2) for (const childId of childIds) { - expect(ctx.agents.get(AgentId(childId))).toBeUndefined() + expect(ctx.agents.get(SessionId(childId))).toBeUndefined() } }) diff --git a/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts b/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts index 7f34396d52..673a43ee0a 100644 --- a/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts @@ -6,10 +6,10 @@ import { expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' import WorkerWorkflowEngine from '../src/index.ts' +import { SessionId } from '@deepseek-ai/dsh-session' // A fresh thread compiles the source runtime. Leave contention headroom on // shared CI runners without weakening any engine-level timeout assertion. @@ -19,7 +19,7 @@ it('runs the default config through the source worker', async () => { const ctx = new Context() const subagents = await ctx.plugin(SubagentService) const engine = await ctx.plugin(WorkerWorkflowEngine, {}) - const parent = { id: AgentId('workflow-compat-parent'), options: {} } as unknown as Agent + const parent = { id: SessionId('workflow-compat-parent'), options: {} } as unknown as Agent try { const run = ctx.workflows.start({ script: 'return 6 * 7', diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts index ac7e5985c1..cbde053d45 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts @@ -1,10 +1,10 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -64,7 +64,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('worker workflow engine with-key it('runs a two-phase script in a worker thread over real children, one through the structured runtime', async () => { ctx = await harness() const parentHandle = await ctx.agents.create({ - agentId: AgentId('wf-worker-e2e-parent'), sessionId: 'wf-worker-e2e-session' as never, agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, }) @@ -97,7 +96,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('worker workflow engine with-key expect(childIds.length).toBe(2) // The children were disposed to quiescence after collection. for (const childId of childIds) { - expect(ctx.agents.get(AgentId(childId))).toBeUndefined() + expect(ctx.agents.get(SessionId(childId))).toBeUndefined() } await parentHandle.dispose() }, 240_000) diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index dffb5c3f9d..2f52cd7b6b 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -3,7 +3,6 @@ import { fileURLToPath } from 'node:url' import type { Worker } from 'node:worker_threads' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import { AgentId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentCapabilities, SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' @@ -11,10 +10,11 @@ import type { WorkflowMeta, WorkflowResult, WorkflowResultInfo, WorkflowRunInfo import * as workerEngineModule from '../src/index.ts' import WorkerWorkflowEngine, { type Config } from '../src/index.ts' import { HostToWorkerType, WorkerToHostType } from '../src/protocol.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** A minimal parent stand-in: the engine only threads it through to the provider. */ function fakeParent(): Agent { - return { id: AgentId('workflow-parent'), options: {} } as unknown as Agent + return { id: SessionId('workflow-parent'), options: {} } as unknown as Agent } // Allow cold worker startup on contended CI runners. @@ -104,7 +104,8 @@ class StubProvider implements SubagentProvider { } if (request.signal.aborted) throw new Error('child start aborted before publication') return { - id: AgentId(`stub-child-${index}`), + id: SessionId(`stub-child-${index}`), + localAgent: undefined, result: terminal.promise, dispose: () => { controlled.disposeCalls += 1 @@ -361,7 +362,8 @@ describe('dsh-workflow-workerthread', () => { capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, inheritsParentContext: false, start: async () => ({ - id: AgentId('reject-child'), + id: SessionId('reject-child'), + localAgent: undefined, result: Promise.reject(new Error('backend exploded')), dispose: () => Promise.resolve(), }), @@ -395,7 +397,8 @@ describe('dsh-workflow-workerthread', () => { stopReason: 'completed', } as unknown as SubagentResult const start = vi.spyOn(ctx.subagents, 'start').mockResolvedValue({ - id: AgentId('raw-invalid-child'), + id: SessionId('raw-invalid-child'), + localAgent: undefined, result: Promise.resolve(invalid), dispose: () => Promise.resolve(), }) @@ -418,7 +421,8 @@ describe('dsh-workflow-workerthread', () => { capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, inheritsParentContext: false, start: async () => ({ - id: AgentId('bad-dispose-child'), + id: SessionId('bad-dispose-child'), + localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }), cancel: () => { /* settled already */ }, dispose: () => { throw new Error('dispose exploded') }, @@ -439,7 +443,8 @@ describe('dsh-workflow-workerthread', () => { capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, inheritsParentContext: false, start: async () => ({ - id: AgentId('trap-child'), + id: SessionId('trap-child'), + localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }), cancel: () => { /* settled already */ }, // The rejection VALUE's own coercion throws: a warn built with bare @@ -766,7 +771,8 @@ describe('dsh-workflow-workerthread', () => { settle({ output: [], stopReason: 'aborted' }) }, { once: true }) return { - id: AgentId('signal-only-child'), + id: SessionId('signal-only-child'), + localAgent: undefined, result, dispose: () => Promise.resolve(), } @@ -1087,7 +1093,8 @@ describe('dsh-workflow-workerthread', () => { expect(request.signal.reason).toBe('workflow worker gone') ready.resolve({ - id: AgentId('late-ready-child'), + id: SessionId('late-ready-child'), + localAgent: undefined, result: Promise.resolve({ output: [], stopReason: 'aborted' }), dispose: () => { disposeCalls += 1 @@ -1123,7 +1130,8 @@ describe('dsh-workflow-workerthread', () => { handle.cancel('reentered from worker-death signal cleanup') }, { once: true }) return { - id: AgentId('doomed-child'), + id: SessionId('doomed-child'), + localAgent: undefined, result: new Promise(() => { /* never settles; the reap is the teardown */ }), dispose: () => Promise.reject(new Error('dispose exploded during reap')), } diff --git a/packages/workflow/workflow/package.json b/packages/workflow/workflow/package.json index 696955db4f..476866382a 100644 --- a/packages/workflow/workflow/package.json +++ b/packages/workflow/workflow/package.json @@ -25,6 +25,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { diff --git a/packages/workflow/workflow/src/types.ts b/packages/workflow/workflow/src/types.ts index 21d8b4a4fb..faef18aeb0 100644 --- a/packages/workflow/workflow/src/types.ts +++ b/packages/workflow/workflow/src/types.ts @@ -7,7 +7,8 @@ */ import type { Branded } from '@deepseek-ai/dsh-brand' -import type { Agent, AgentId } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { SessionId } from '@deepseek-ai/dsh-session' /** Identifies one workflow run. */ export type WorkflowRunId = Branded<'WorkflowRunId'> @@ -141,7 +142,7 @@ export interface WorkflowAgentInfo { /** The phase this agent belongs to (the `phase` option, else the current `phase()` title). */ phase?: string /** The child agent's id on the subagent seam. */ - childId: AgentId + childId: SessionId } /** How one `agent()` call settled: clean result, child failure (script sees `null`), or run cancellation. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ef384352f..fa43d82945 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -780,6 +780,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop '@deepseek-ai/dsh-agent-spine-demo': specifier: workspace:^ version: link:../agent-spine-demo @@ -1456,12 +1459,18 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -1490,6 +1499,9 @@ importers: '@deepseek-ai/dsh-loader-smoke': specifier: workspace:^ version: link:../../support/loader-smoke + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent @@ -1775,6 +1787,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent @@ -1985,6 +2000,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -2032,6 +2050,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -2042,7 +2063,7 @@ importers: specifier: workspace:^ version: link:../user-interaction cordis: - specifier: ^4.0.0-rc.6 + specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) packages/ui/tool-ask-user: diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index ed575858a6..716bdb24ac 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -400,6 +400,7 @@ function builtBinSmokeGate(): Gate { 'vitest.e2e.config.ts', 'packages/examples/stdio-demo/tests/built-bin.e2e.ts', 'packages/examples/acp-demo/tests/built-bin.e2e.ts', + 'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts', // The worker-entry packages' built bundles: the only automated proof // that lib/index.js resolves its sibling lib/worker.cjs under plain node // (the e2e lane runs unbuilt, so these files self-skip there). diff --git a/website/zh-CN/api/harness/agent-loop.md b/website/zh-CN/api/harness/agent-loop.md index 9d840e8aeb..79003deb84 100644 --- a/website/zh-CN/api/harness/agent-loop.md +++ b/website/zh-CN/api/harness/agent-loop.md @@ -4,25 +4,25 @@ `AgentLoop` — provided by `@deepseek-ai/dsh-agent-loop`. -Concrete ReactLoopAgent factory and driver service. +Concrete agent factory and driver service. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L353) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L408) ### ctx.agentLoop.create(id, options?, meta?) ```ts website-api -create(id: AgentId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent +create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): Agent ``` -Create an agent on a fresh per-run session, owned by the accessing fiber. Constructor-driven config calls use the loop fiber itself. +Create an agent and session under one caller-supplied identity, owned by the accessing fiber. Constructor-driven config calls mint a fresh combined id before entering this boundary. -- `id` — agent registry id. +- `id` — shared agent/session identity. - `options` — concrete loop options. - `meta` — optional fresh-session workspace metadata. **Returns** the published running agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L413) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L543) ### ctx.agentLoop.createAgent(ownerCtx, options) @@ -37,7 +37,7 @@ Create an owned agent on a caller-supplied session id. **Returns** the published handle. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L436) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L565) ### ctx.agentLoop.resume(ownerCtx, options) @@ -52,4 +52,4 @@ Resume an owned agent from the configured persistence service. **Returns** the published handle. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L468) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L597) diff --git a/website/zh-CN/api/harness/agents.md b/website/zh-CN/api/harness/agents.md index f6c9565d37..8d6c84cc7a 100644 --- a/website/zh-CN/api/harness/agents.md +++ b/website/zh-CN/api/harness/agents.md @@ -6,7 +6,7 @@ Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator plugins can find them without depending on the concrete loop package. Agent *creation* is provided by whichever plugin implements the AgentFactory (`@deepseek-ai/dsh-agent-loop`), registered via setFactory. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L133) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L201) ### ctx.agents.setFactory(factory) @@ -14,13 +14,13 @@ Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator setFactory(factory: AgentFactory): () => void ``` -Register the effect-scoped creation factory, rejecting a duplicate. Service factories are retraced through each create/resume caller for ownership. +Register the agent-creation factory (the loop calls this on construction, effect-scoped). A traced Cordis service is canonicalized to its concrete target; each create/resume call is then traced through that caller's context so ownership follows the caller without stacking proxy layers. Throws if a factory is already registered. Returns the disposer; on dispose the factory slot is cleared. - `factory` — the loop-owned factory `create`/`resume` delegate to. -**Returns** the exact Cordis effect disposer. +**Returns** the disposer that clears the factory slot. The exact Cordis effect disposer (single-shot): composite (generator) effects may yield it directly — exact identity nests the teardown in order. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L152) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L228) ### ctx.agents.create(options) @@ -28,13 +28,13 @@ Register the effect-scoped creation factory, rejecting a duplicate. Service fact async create(options: CreateAgentOptions): Promise ``` -Create and publish an owned agent and session through the active factory. Rejects if no factory is registered or creation, setup, or publication fails. +Create and publish a new agent through the registered factory. Distinct from register (which records an already-constructed agent): this constructs the agent and its session. Rejects if no factory is registered or creation/setup fails. The resolved AgentHandle lets the owner tear down exactly this agent. -- `options` — agent id, session id/seed/metadata, and agent options. +- `options` — shared identity, session seed/metadata, and agent options. **Returns** the handle after setup, rollback-covered publication, and loop start complete. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L177) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L261) ### ctx.agents.resume(options) @@ -48,7 +48,7 @@ Load a persisted session and resume an agent on it through the registered factor **Returns** the handle after setup, rollback-covered publication, and loop start complete. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L193) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L280) ### ctx.agents.register(agent) @@ -56,27 +56,28 @@ Load a persisted session and resume an agent on it through the registered factor register(agent: Agent): () => void ``` -Register a live agent in the calling effect scope, with scope-filtered creation and disposal events. Duplicate ids throw. +Register a live agent. Throws if an agent with the same id is already registered. Emits `agent/created` on registration and `agent/disposed` when the calling fiber is disposed — both with the agent's scope carrier (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the emits are scope-filtered regardless of which context invoked `register` (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always requires passing the carrier). Returns the disposer. - `agent` — the already-constructed agent to record in the store. -**Returns** the exact Cordis effect disposer for nested teardown ordering. +**Returns** the EXACT Cordis effect disposer (single-shot; a repeat call returns undefined without awaiting an in-flight teardown). Exact identity is load-bearing: a composite (generator) effect that owns a teardown ORDER — the agent factory's lifecycle chain — must yield THIS function so Cordis nests the unregistration at that yield position; yielding a wrapper would leave it disposing as a concurrent sibling on owner unload, unregistering the agent (and emitting `agent/disposed`) while its final turn is still draining. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L207) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L306) -### ctx.agents.enter(agent) +### ctx.agents.enter(agent, owner) ```ts website-api -enter(agent: Agent): () => void +enter(agent: Agent, owner: Agent | undefined): () => void ``` -Insert an unpublished agent for an ordered factory transaction. +Insert an already-constructed agent without announcing it. This is the advanced ordered-lifecycle primitive used by the async agent factory: it first completes setup while the agent is unpublished, then assigns the returned detach closure into its pre-installed composite teardown before calling announce. Ordinary callers use register. - `agent` — the prepared, unpublished agent. +- `owner` — live agent whose scoped context created this agent, or undefined for a top-level runtime root. This is runtime ownership, not the resumed session's durable parent lineage. -**Returns** an idempotent closure that removes this exact entry and emits the paired disposal edge; detachment during creation dispatch is deferred. +**Returns** an idempotent closure that removes this exact entry and emits `agent/disposed` with listener failures contained. When called from a synchronous `agent/created` listener, removal and disposal wait until that creation dispatch unwinds. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L222) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L330) ### ctx.agents.announce(agent) @@ -88,21 +89,36 @@ Announce an agent previously inserted with enter. - `agent` — the live inserted agent to announce. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L290) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L405) ### ctx.agents.get(id) ```ts website-api -get(id: AgentId): Agent | undefined +get(id: SessionId): Agent | undefined ``` Look up a live agent. -- `id` — the agent id to look up. +- `id` — the shared agent/session id to look up. **Returns** the agent, or undefined when no live agent has that id. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L324) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L439) + +### ctx.agents.isOwnedBy(id, owner) + +```ts website-api +isOwnedBy(id: SessionId, owner: Agent): boolean +``` + +Test whether a live agent was created through one exact parent agent's scoped context. Runtime ownership is independent of durable session lineage and remains unambiguous when unrelated providers reuse an id. + +- `id` — the candidate child agent's shared agent/session id. +- `owner` — the expected runtime creator agent. + +**Returns** true only while the exact child entry is live under that owner. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L451) ### ctx.agents.list() @@ -114,4 +130,16 @@ All live agents, in registration order. **Returns** a fresh array; mutating it does not affect the registry. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L332) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L459) + +### ctx.agents.roots() + +```ts website-api +roots(): Agent[] +``` + +All live top-level agents in registration order. A top-level agent was created without an owning agent context; durable session lineage does not affect this runtime relation, so a resumed fork may still be a root. + +**Returns** a fresh array; mutating it does not affect the registry. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L469) diff --git a/website/zh-CN/api/harness/compact.md b/website/zh-CN/api/harness/compact.md index 8be3b4287a..67f17cc941 100644 --- a/website/zh-CN/api/harness/compact.md +++ b/website/zh-CN/api/harness/compact.md @@ -25,20 +25,19 @@ Check token pressure and compact if the conversation is too large. Estimate the [Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/compact/compact/src/index.ts#L58) -### ctx.compact.compactRegion(session, start, end, agent, signal?) +### ctx.compact.compactRegion(start, end, agent, signal?) ```ts website-api -abstract compactRegion( session: Session, start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise +abstract compactRegion( start: number, end: number, agent: CompactAgentContext, 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 order; replacements can make visible seqs non-monotonic. Both edges must be balanced so assistant tool calls remain paired with their results. A model- backed implementation forwards cancellation. The agent must own the exact target session object; implementations reject an ownership mismatch before model resolution, lock acquisition, summarization, or log mutation, and reject active, missing, reversed, or unbalanced ranges. Use toolPairingBalancedBefore and toolPairingBalancedAfter for the edge checks. +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 order; replacements can make visible seqs non-monotonic. Both edges must be balanced so assistant tool calls remain paired with their results. A model- backed implementation forwards cancellation and rejects active, missing, reversed, or unbalanced ranges. The target session is `agent.session`. Use toolPairingBalancedBefore and toolPairingBalancedAfter for the edge checks. -- `session` — session to mutate; must be identical to `agent.session`. - `start` — first surface seq, inclusive. - `end` — last surface seq, inclusive. -- `agent` — owner of the target session and summarizer context. +- `agent` — context whose session is mutated and whose routing options guide summarization. - `signal` — optional cancellation; model-backed implementations must forward it. -**Returns** the replaced range and summary. +**Returns** the appended event seqs, summary, replaced range, and token accounting. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/compact/compact/src/index.ts#L85) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/compact/compact/src/index.ts#L82) diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index 5f99cb4f32..ca54f39176 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -2,7 +2,7 @@ # Harness events -Every event the harness packages declare on the cordis event bus (39 total), grouped by scope. The **mode** is the dispatch semantics (`emit` fire-and-forget, `parallel` awaited, `serial` first-bail, `waterfall` veto-chain — a waterfall listener MUST call `next()` to delegate). +Every event the harness packages declare on the cordis event bus (40 total), grouped by scope. The **mode** is the dispatch semantics (`emit` fire-and-forget, `parallel` awaited, `serial` first-bail, `waterfall` veto-chain — a waterfall listener MUST call `next()` to delegate). ## agent/* @@ -18,7 +18,7 @@ A fully configured agent and live session were published. Setup is composition-o - `agent` — the newly registered agent with its live session and completed setup. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L154) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L141) ### agent/disposed @@ -32,7 +32,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef - `agent` — the exact agent removed from the registry. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L163) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L150) ### agent/error @@ -49,7 +49,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w - `step` — the step at which the failure surfaced. - `error` — the failure, verbatim. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L298) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L285) ### agent/pre-step @@ -68,7 +68,7 @@ Awaited serial checkpoint for session-surface mutation after prompt assembly and - `sessionPrefix` — the frozen request prefix. - `signal` — the turn abort signal. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L217) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L204) ### agent/prompt-submit @@ -84,7 +84,7 @@ Allow, rewrite, or block one drained prompt before it becomes a user message. Ca - `content` — the drained message's blocks, as queued. - `source` — the message's resolved source. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L227) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L214) ### agent/queued @@ -100,7 +100,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already - `content` — the accepted content blocks retained by the inbox. - `info` — the accepted source plus whether it entered as steering. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L182) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L169) ### agent/request @@ -117,7 +117,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha - `step` — the step whose request this is. - `config` — the config the loop would use (frozen); return a replacement to switch. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L239) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L226) ### agent/session-prefix @@ -133,7 +133,7 @@ Compose request-only messages placed before derived history. The frozen result i - `prefix` — the frozen seed; return an extended replacement. - `signal` — aborts composition when the step is torn down. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L254) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L241) ### agent/session-start @@ -148,7 +148,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to - `agent` — the agent whose session lifecycle began. - `source` — why the session started (fresh startup, resume, …). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L195) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L182) ### agent/status @@ -163,7 +163,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no - `agent` — the agent whose status flipped. - `status` — the status just entered (the transition's destination). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L172) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L159) ### agent/step-result @@ -180,7 +180,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va - `step` — the step that produced the message. - `message` — the assistant message as assembled from the stream. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L265) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L252) ### agent/turn-continuation @@ -196,7 +196,7 @@ Override whether the turn continues. The default continues after tool calls or s - `turn` — the turn being continued or stopped. - `defaultDecision` — what the loop would do absent an override. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L275) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L262) ### agent/turn-stop @@ -211,7 +211,24 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a - `agent` — the agent whose composed continuation outcome may be stopped. - `turn` — the turn at its terminal-stop checkpoint. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L285) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L272) + +## agent-loop/* + +### agent-loop/config-start-failed + +**Mode:** `emit` + +```ts website-api +'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void +``` + +A declarative agent entry failed before it could publish a live agent. Consumers that buffer work for the configured identity use this transient signal to reject that work instead of waiting forever. Normal factory teardown suppresses failures from the cancelled startup attempt. + +- `sessionId` — exact shared agent/session identity that failed startup. +- `error` — persistence, setup, or publication failure. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L363) ## approval/* @@ -366,7 +383,7 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c - `info` — the run identity and terminal outcome. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L108) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L112) ### subagent/provider-added @@ -380,7 +397,7 @@ A provider became resolvable in the registry. - `provider` — the registered provider. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L82) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L86) ### subagent/provider-removed @@ -394,7 +411,7 @@ A provider left the registry. Accepted runs remain holder-owned. - `name` — the provider name that no longer resolves. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L88) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L92) ### subagent/start @@ -408,7 +425,7 @@ A provider established a ready child. For in-process providers, `ctx.agents.get( - `info` — the provider and ready child identity. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L99) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L103) ## system-prompt/* diff --git a/website/zh-CN/api/harness/llm.md b/website/zh-CN/api/harness/llm.md index 200e926d15..a70d5bd478 100644 --- a/website/zh-CN/api/harness/llm.md +++ b/website/zh-CN/api/harness/llm.md @@ -6,7 +6,7 @@ The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L96) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L94) ### ctx.llm.registerAdapter(providers, adapter) @@ -21,7 +21,7 @@ Register an adapter for the given provider routes. Throws `LlmError` with code ` **Returns** the disposer that unregisters all of them. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L111) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L109) ### ctx.llm.listProviders() @@ -33,7 +33,7 @@ Describe provider routes with a registered adapter. **Returns** detached provider metadata in registration order. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L142) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L140) ### ctx.llm.listModels(provider) @@ -47,7 +47,7 @@ Discover models advertised by one registered provider. Catalog membership is adv **Returns** detached model metadata in adapter-preferred order. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L152) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L150) ### ctx.llm.stream(options) @@ -61,4 +61,4 @@ Stream one model call as raw chunks (token-level deltas). Throws `LlmError` with **Returns** the chunk stream, possibly wrapped by `llm/stream` listeners. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L210) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L208) diff --git a/website/zh-CN/api/harness/sessions.md b/website/zh-CN/api/harness/sessions.md index e5c8997c49..e39006dd88 100644 --- a/website/zh-CN/api/harness/sessions.md +++ b/website/zh-CN/api/harness/sessions.md @@ -7,7 +7,7 @@ In-memory session store (`ctx.sessions`). Persistence is intentionally not implemented here — persistence plugins subscribe to `session/event` and flush on `session/flush` / dispose. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L580) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L585) ### ctx.sessions.create(id?, options?) @@ -23,7 +23,7 @@ For an agent whose session must be torn down IN ORDER with its loop (so the loop **Returns** the live session, already entered and announced. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L609) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L614) ### ctx.sessions.prepare(id?, options?) @@ -38,7 +38,7 @@ Build a session WITHOUT entering it into the store — validate the id/cwd and c **Returns** the constructed session, NOT yet in the store. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L638) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L643) ### ctx.sessions.enter(session) @@ -53,7 +53,7 @@ Re-checks the id for a duplicate: `prepare` and `enter` are public cross-package **Returns** the detach disposer (publication hooks + store removal). When called from a synchronous `session/created` listener, removal and disposal wait until that creation dispatch unwinds. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L682) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L687) ### ctx.sessions.announce(session) @@ -65,7 +65,7 @@ Emit `session/created` exactly once for an entered session (with the carrier ent - `session` — the entered session to announce to listeners. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L737) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L742) ### ctx.sessions.flush(session) @@ -79,7 +79,7 @@ Dispatch the awaited `session/flush` durability checkpoint for `session`, with t **Returns** resolves when every flush listener has settled; after all settle, rejects with the first registered listener failure if any listener failed. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L789) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L794) ### ctx.sessions.get(id) @@ -93,7 +93,7 @@ Look up a live session. **Returns** the session, or undefined when no live session has that id. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L821) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L826) ### ctx.sessions.list() @@ -105,7 +105,7 @@ All live sessions, in creation order. **Returns** a fresh array; mutating it does not affect the store. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L829) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L834) ### ctx.sessions.fork(source, boundary?, childSessionId?) @@ -121,4 +121,4 @@ Create a live child session from a turn-enclosed prefix of a live source. `bound **Returns** The created live child session. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L846) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L851) diff --git a/website/zh-CN/api/harness/subagents.md b/website/zh-CN/api/harness/subagents.md index e1fb492723..52632e820d 100644 --- a/website/zh-CN/api/harness/subagents.md +++ b/website/zh-CN/api/harness/subagents.md @@ -6,7 +6,7 @@ Named provider registry and capability-checked start surface. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L141) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L153) ### ctx.subagents.registerProvider(provider) @@ -20,7 +20,7 @@ Register a provider under its name. Registration is effect-scoped and HMR safe; **Returns** the exact Cordis effect disposer. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L155) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L167) ### ctx.subagents.getProvider(name) @@ -34,7 +34,7 @@ Look up a provider by name. **Returns** the provider, or undefined when absent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L178) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L190) ### ctx.subagents.list() @@ -46,7 +46,7 @@ List registered provider names in insertion order. **Returns** the registered names. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L186) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L198) ### ctx.subagents.start(name, request) @@ -61,4 +61,4 @@ Establish a ready child on the named provider. Capability and semantic checks ru **Returns** the ready holder-owned run. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L199) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L211)