From d6a2ab30c81d59d303a414cf75ebca15f6357263 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 07:17:25 +0800 Subject: [PATCH] feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes the two gaps in the "brand ids that cross package boundaries" policy and fixes the dependency direction so a capability package never pulls in an unrelated one. - Extract the `Branded` primitive into a new standalone type-only package `@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps. dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session, dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a generic execution backend must not couple to the LLM or session vocabulary). - Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id, the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary that casts SessionId -> OwnerToken. - Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters at the config boundary and the inner create()/resume casts disappear (only the genuinely-new per-run session-id string is cast). - Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store Map keys and public params/exports (SessionStore, AgentRegistry + factory options, the ACP session-id surface + ToolPresenter CallId map, the persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps). - Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point the Branded type-equiv at dsh-brand, fix stale param types in the session/ agent/bash READMEs, regenerate the cordis catalog + module graph. Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md --- docs/cookbook/extension-cookbook.md | 3 +- docs/cordis-catalog/events-and-services.md | 50 +++++++------- docs/core-data-structures/bash.md | 8 ++- docs/core-data-structures/core.md | 6 +- docs/module-graph.md | 13 ++-- docs/rfc/README.md | 2 +- .../architecture/2026-06-20-branded-ids.md | 6 +- .../2026-06-20-prune-dead-seam-methods.md | 2 +- .../coding-agent/tests/coding-task.e2e.ts | 3 +- examples/coding-agent/tests/full-loop.e2e.ts | 3 +- examples/coding-agent/tests/resume.e2e.ts | 8 ++- knip.json | 4 ++ packages/bash/bash-local/src/index.ts | 18 ++--- .../bash/bash-local/tests/executor.spec.ts | 6 +- packages/bash/bash-local/tsconfig.json | 3 + packages/bash/bash/README.md | 2 +- packages/bash/bash/package.json | 2 + packages/bash/bash/src/index.ts | 11 ++-- packages/bash/bash/src/types.ts | 31 ++++++++- packages/bash/bash/tests/service.spec.ts | 16 ++--- packages/bash/bash/tsconfig.json | 3 + packages/bash/tool-bash/src/index.ts | 12 ++-- .../bash/tool-bash/tests/integration.spec.ts | 11 ++-- packages/bash/tool-bash/tests/tools.spec.ts | 44 ++++++------- packages/core/agent-loop/src/index.ts | 34 ++++++---- packages/core/agent-loop/tests/agent.spec.ts | 46 ++++++------- packages/core/agent-loop/tests/cancel.spec.ts | 26 ++++---- .../tests/config-session-id.spec.ts | 22 +++---- .../agent-loop/tests/coverage-edges.spec.ts | 22 +++---- packages/core/agent-loop/tests/loop.spec.ts | 60 ++++++++--------- .../core/agent-loop/tests/properties.spec.ts | 8 +-- packages/core/agent-loop/tests/resume.spec.ts | 32 ++++----- .../agent-loop/tests/review-fixes.spec.ts | 66 +++++++++---------- packages/core/agent/README.md | 2 +- packages/core/agent/package.json | 2 + packages/core/agent/src/index.ts | 14 ++-- packages/core/agent/src/types.ts | 3 +- packages/core/agent/tests/agent.spec.ts | 24 +++---- packages/core/agent/tsconfig.json | 3 + packages/core/session/README.md | 4 +- packages/core/session/package.json | 2 + packages/core/session/src/index.ts | 8 +-- packages/core/session/src/types.ts | 3 +- packages/core/session/tests/session.spec.ts | 42 ++++++------ packages/core/session/tsconfig.json | 3 + packages/llm/llm-pi-ai/src/adapter.ts | 7 +- packages/llm/llm-pi-ai/src/convert.ts | 2 +- packages/llm/llm/package.json | 2 + packages/llm/llm/src/brand.ts | 21 ++---- packages/llm/llm/tsconfig.json | 3 + .../tests/jsonl.spec.ts | 22 +++---- .../tests/sqlite.spec.ts | 6 +- .../session-persistence/src/coordinator.ts | 4 +- .../tests/coordinator-contract.ts | 42 ++++++------ packages/support/invariants/src/index.ts | 3 +- .../invariants/tests/invariants.spec.ts | 6 +- packages/support/ui-stdio/src/index.ts | 4 +- packages/ui/acp/src/index.ts | 58 ++++++++-------- packages/ui/acp/tests/bridge.spec.ts | 9 +-- packages/ui/acp/tests/dispose.spec.ts | 55 ++++++++-------- packages/ui/acp/tests/edges.spec.ts | 4 +- packages/ui/acp/tests/load.spec.ts | 9 +-- packages/ui/acp/tests/multi-session.spec.ts | 5 +- packages/ui/acp/tests/properties.spec.ts | 4 +- packages/ui/acp/tests/stream-update.spec.ts | 10 +-- packages/ui/acp/tests/turns.spec.ts | 5 +- packages/util/brand/README.md | 26 ++++++++ packages/util/brand/package.json | 28 ++++++++ packages/util/brand/src/index.ts | 27 ++++++++ packages/util/brand/tsconfig.json | 11 ++++ pnpm-lock.yaml | 18 +++++ scripts/type-equiv.manifest.json | 2 +- tsconfig.base.json | 1 + tsconfig.build.json | 1 + tsconfig.typecheck.json | 1 + 75 files changed, 644 insertions(+), 445 deletions(-) rename docs/rfc/{proposed => implemented}/architecture/2026-06-20-branded-ids.md (96%) create mode 100644 packages/util/brand/README.md create mode 100644 packages/util/brand/package.json create mode 100644 packages/util/brand/src/index.ts create mode 100644 packages/util/brand/tsconfig.json diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 1739acd32c..1db79f627b 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -38,6 +38,7 @@ A UI plugin consumes `agent/stream-chunk` and session events for rendering, and ```ts import type { Context } from 'cordis' +import { AgentId } from '@deepseek-ai/dsh-agent' declare function render(text: string): void declare function onUserInput(handler: (text: string) => void): void @@ -49,7 +50,7 @@ export function apply(ctx: Context) { ctx.on('agent/stream-chunk', (agent, turn, step, chunk) => { if (chunk.type === 'text-delta') render(chunk.text) }) - onUserInput(text => ctx.agents.get('main')?.send([{ type: 'text', text }])) + onUserInput(text => ctx.agents.get(AgentId('main'))?.send([{ type: 'text', text }])) } ``` diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 0422555ec2..de5cf6ad72 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -25,7 +25,7 @@ An agent was registered in the AgentRegistry and is ready to receive messages. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:140`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:141`](../../packages/core/agent/src/types.ts) #### `agent/disposed` — emit @@ -37,7 +37,7 @@ An agent was disposed and removed from the registry; its fiber and any in-flight Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:146`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:147`](../../packages/core/agent/src/types.ts) #### `agent/error` — emit @@ -49,7 +49,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:223`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:224`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -61,7 +61,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv 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:159`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:160`](../../packages/core/agent/src/types.ts) #### `agent/request` — waterfall @@ -73,7 +73,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:192`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:193`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -85,7 +85,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:153`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:154`](../../packages/core/agent/src/types.ts) #### `agent/steering` — emit @@ -97,7 +97,7 @@ Steering content was injected into a running turn. 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:217`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:218`](../../packages/core/agent/src/types.ts) #### `agent/step-end` — emit @@ -109,7 +109,7 @@ A step ended. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:183`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:184`](../../packages/core/agent/src/types.ts) #### `agent/step-result` — waterfall @@ -121,7 +121,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:198`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:199`](../../packages/core/agent/src/types.ts) #### `agent/step-start` — emit @@ -133,7 +133,7 @@ A step (one model call plus its tool dispatch) began. `step` is 1-based within t Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:178`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:179`](../../packages/core/agent/src/types.ts) #### `agent/stream-chunk` — emit @@ -145,7 +145,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed). Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/core/agent/src/types.ts:212`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:213`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -157,7 +157,7 @@ Waterfall: override the turn-continuation decision. The default (computed by the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:205`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:206`](../../packages/core/agent/src/types.ts) #### `agent/turn-end` — emit @@ -169,7 +169,7 @@ A turn ended. `reason` distinguishes a clean stop from a truncated or aborted on Types: [Agent](../core-data-structures/core.md) · [TurnEndReason](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:172`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:173`](../../packages/core/agent/src/types.ts) #### `agent/turn-start` — emit @@ -181,7 +181,7 @@ A turn began. `turn` is the 1-based turn number within the session. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:166`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:167`](../../packages/core/agent/src/types.ts) ### `llm/*` @@ -288,12 +288,12 @@ The agent-loop plugin (`ctx.agentLoop`): creates ReactLoopAgents, runs their loo The loop itself is deliberately thin — every behavior beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy declared in @deepseek-ai/dsh-agent. ```ts cordis-catalog -create(id: string, options: AgentOptions = {}): ReactLoopAgent +create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent createAgent(options: CreateAgentOptions): AgentHandle async resume(options: ResumeAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:60`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:63`](../../packages/core/agent-loop/src/index.ts) ### `ctx.agents` — `AgentRegistry` @@ -304,7 +304,7 @@ setFactory(factory: AgentFactory): () => void create(options: CreateAgentOptions): AgentHandle async resume(options: ResumeAgentOptions): Promise register(agent: Agent): () => void -get(id: string): Agent | undefined +get(id: AgentId): Agent | undefined list(): Agent[] ``` @@ -327,17 +327,17 @@ Semantics every implementation must honor: abstract resolve(request: BashExecRequest): BashExecSpec abstract run(spec: BashExecSpec): Promise abstract start(spec: BashExecSpec): BashTask -abstract get(id: string): BashTask | undefined -abstract ownerOf(id: string): string | undefined +abstract get(id: BashTaskId): BashTask | undefined +abstract ownerOf(id: BashTaskId): OwnerToken | undefined abstract list(): BashTask[] -abstract readOutput(id: string): BashTaskRead -abstract kill(id: string): boolean +abstract readOutput(id: BashTaskId): BashTaskRead +abstract kill(id: BashTaskId): boolean onTaskDone(listener: BashTaskListener): () => void ``` Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md) · [BashTask](../core-data-structures/bash.md) · [BashTaskRead](../core-data-structures/bash.md) -Source: [`packages/bash/bash/src/index.ts:58`](../../packages/bash/bash/src/index.ts) +Source: [`packages/bash/bash/src/index.ts:59`](../../packages/bash/bash/src/index.ts) ### `ctx.llm` — `LlmService` @@ -382,11 +382,11 @@ In-memory session store (`ctx.sessions`). Persistence is intentionally not implemented here — persistence plugins subscribe to `session/event` and flush on `session/flush` / dispose. ```ts cordis-catalog -create(id?: string, options?: CreateSessionOptions): Session -prepare(id?: string, options?: CreateSessionOptions): Session +create(id?: SessionId, options?: CreateSessionOptions): Session +prepare(id?: SessionId, options?: CreateSessionOptions): Session enter(session: Session): () => void announce(session: Session): void -get(id: string): Session | undefined +get(id: SessionId): Session | undefined list(): Session[] ``` diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index c601d8cd74..807c7401fc 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -25,7 +25,7 @@ interface BashExecRequest { * seam — that is the consumer's job). Absent for foreground runs and for an * ownerless background start (a non-agent caller). */ - owner?: string | undefined + owner?: OwnerToken | undefined } ``` @@ -44,12 +44,14 @@ interface BashExecSpec { * silently-absent property that yields an unowned (cross-session-readable) * task. `start()` stores it; `run()` (foreground) ignores it. */ - owner: string | undefined + owner: OwnerToken | undefined } ``` The `owner` token is the isolation key: the executor stores it but never interprets it (access policy is the consumer's job), so a background task started by one agent isn't readable cross-session. A required-but-nullable field makes a forgotten owner a visible `undefined` rather than a silently-unowned task. +Both ids the seam handles are [branded](core.md) (zero-cost `string` brands, the same machinery as `SessionId`/`AgentId`): `BashTaskId` (a tracked background task, generated `bash-N` by the local executor) and `OwnerToken` (the opaque isolation key). `OwnerToken` is deliberately a DISTINCT brand from `SessionId`, not an alias: the bash seam is a capability seam that must not know what an owner token *means*, so it never imports `dsh-session`'s vocabulary — the `dsh-tool-bash` consumer is the single boundary that casts the owning agent's `SessionId` into an `OwnerToken`. Branding both stops a raw `string` (or a `BashTaskId` where an `OwnerToken` is expected, or vice versa) from slipping through the type checker on the model-facing `task_id` path. + ## Foreground runs: `BashRunResult` The outcome of one completed (or killed) foreground run. Orthogonal outcomes are reported **independently** — a process can both time out AND exit 0 because it trapped the signal — so `timedOut`, `aborted`, `signal`, and `exitCode` are each their own field; a caller never reads a cut-short run as a clean success. @@ -90,7 +92,7 @@ A long-running command started with `start()` is tracked as a `BashTask`. `BashT ```ts type-equiv interface BashTask { - readonly id: string + readonly id: BashTaskId readonly command: string status: BashTaskStatus /** Exit code once finished (null = killed by signal / still running). */ diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index a79255a5c7..46b416724e 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -61,13 +61,15 @@ Two large discriminated unions are the ones consumers `switch` over most: **`Str 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. -Source: [`packages/llm/llm/src/brand.ts`](../../packages/llm/llm/src/brand.ts) +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 (e.g. dsh-bash brands `BashTaskId`/`OwnerToken` via dsh-brand alone, never pulling in dsh-llm). + +Source: [`packages/util/brand/src/index.ts`](../../packages/util/brand/src/index.ts) ```ts type-equiv type Branded = string & { readonly [BRAND]: B } ``` -The three core IDs: `CallId` (correlates a tool call with its result; dsh-llm), `SessionId` (dsh-session), `AgentId` (dsh-agent). Each is `Branded<'CallId'>` etc. plus a same-named factory function. +The three core IDs: `CallId` (correlates a tool call with its result; dsh-llm), `SessionId` (dsh-session), `AgentId` (dsh-agent). Each is `Branded<'CallId'>` etc. plus a same-named factory function. Capability seams brand their own ids too — see `BashTaskId`/`OwnerToken` in [bash.md](bash.md). ## Content blocks and messages diff --git a/docs/module-graph.md b/docs/module-graph.md index 037c6be8c8..92a2e7a09b 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -7,11 +7,15 @@ Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, deri ```mermaid graph TD + bash --> brand + llm --> brand bash-local --> bash llm-deepseek --> llm llm-pi-ai --> llm + session --> brand session --> llm system-prompt --> llm + agent --> brand agent --> llm agent --> session llm-replay --> llm @@ -49,14 +53,15 @@ graph TD | Package | Depends on | | --- | --- | -| `bash` | — | -| `llm` | — | +| `brand` | — | +| `bash` | `brand` | +| `llm` | `brand` | | `bash-local` | `bash` | | `llm-deepseek` | `llm` | | `llm-pi-ai` | `llm` | -| `session` | `llm` | +| `session` | `brand`, `llm` | | `system-prompt` | `llm` | -| `agent` | `llm`, `session` | +| `agent` | `brand`, `llm`, `session` | | `llm-replay` | `llm`, `session` | | `session-persistence` | `session` | | `invariants` | `agent`, `llm`, `session` | diff --git a/docs/rfc/README.md b/docs/rfc/README.md index be22e0c84b..eaefbdda94 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -61,7 +61,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/architecture/2026-06-16-typed-event-schemas.md) | 2026-06-16 | | [Extract a generic long-running tool runtime](proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 | | [Extract example apps into packages](proposed/architecture/2026-06-20-extract-example-app-packages.md) | 2026-06-20 | -| [Branded IDs everywhere they belong](proposed/architecture/2026-06-20-branded-ids.md) | 2026-06-20 | ### Process @@ -116,6 +115,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Shared persistence write coordinator](implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) | 2026-06-18 | | [Agent lifecycle and ownership seams](implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md) | 2026-06-18 | | [Reorganize packages into a modular hierarchy](implemented/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 | +| [Branded IDs everywhere they belong](implemented/architecture/2026-06-20-branded-ids.md) | 2026-06-20 | ### Process diff --git a/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md similarity index 96% rename from docs/rfc/proposed/architecture/2026-06-20-branded-ids.md rename to docs/rfc/implemented/architecture/2026-06-20-branded-ids.md index 93a4bf6cda..c203e76d42 100644 --- a/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md +++ b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md @@ -1,6 +1,6 @@ # RFC: Branded IDs everywhere they belong -Status: proposed +Status: implemented (proposed and accepted 2026-06-20) ## Problem @@ -8,7 +8,7 @@ The harness already brands three identifiers — `CallId` (`packages/llm/llm/src **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](../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 `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". **Gap 2 — brand erosion at the seams of the *already-branded* IDs.** Even `CallId`/`SessionId`/`AgentId` decay back to bare `string` at exactly the places confusion is most likely: the registry/store `Map` key types and most public method params. Representative sites: `SessionStore.store = new Map()` and `create`/`prepare(id?: string)`/`get(id: string)` (`packages/core/session/src/index.ts`); `AgentRegistry.store = new Map()` and `register`/`get(id: string)` (`packages/core/agent/src/index.ts`); `ToolPresenter.pending = new Map()` keyed by call id and `call(callId: string)`/`result(callId: string)` (`packages/ui/acp/src/index.ts`); the ACP session-id surface beyond the store map — `SessionRecord.sessionId: string`, `bySession = new WeakMap()`, `loadingIds = new Set()`, `requireSession(sessionId: string)`, and the exported `streamSessionEventUpdate(sessionId: string, …)` (`packages/ui/acp/src/index.ts`); and the persistence coordinator's `Map` keyed by session id (`packages/session-persistence/session-persistence/src/coordinator.ts`). A brand that is dropped at the `Map` key buys nothing on lookups — the value of the existing brands is partly unrealized. @@ -63,6 +63,6 @@ Kept deliberately narrow per the "not every string needs a brand" policy. Each o ## Risks / what we give up -- **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 risk is broad but low-severity: a missed site is a compile error, not a silent bug. It ships as its own PR, converged with Codex, and stacks naturally near the [unify-the-agent-id-and-the-session-id](../simplification/2026-06-20-unify-agent-and-session-id.md) work (both touch the session-id / owner-token boundary; if that proposal lands first, `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 risk is broad but low-severity: a missed site is a compile error, not a silent bug. It ships as its own PR, converged with Codex, and stacks naturally near the [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) work (both touch the session-id / owner-token boundary; if that proposal lands first, `OwnerToken` still 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/simplification/2026-06-20-prune-dead-seam-methods.md b/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md index 3cf99e47ef..1eec8fd36b 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md +++ b/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md @@ -2,7 +2,7 @@ Status: implemented (proposed and accepted 2026-06-20) -> **Implementation note (scope narrowed from the original proposal).** This RFC proposed pruning dead methods from BOTH the persistence seam (`SessionPersistence.has()`/`.delete()`) and the bash seam (`BashExecutor.get()`/`.list()`). Only the **persistence** removal shipped. The bash `get()`/`.list()` removal was reverted before merge: each is a one-line accessor over the executor's already-tracked `tasks` map, and removing them forced `dsh-tool-bash`'s tests onto a ~35-line `onTaskDone`-based completion-tracking harness to replace the one-line `ctx.bash.get(id)` lookup — the migration cost dwarfed the surface removed. Per the [AGENTS.md "RFCs are proposals, not golden truth"](../../../../AGENTS.md) principle, that friction is evidence the method earns its keep (a test harness IS a consumer that programs against the seam), so `get()`/`list()` stay. The bash-seam analysis below is retained for the record but was NOT acted on; `BashTaskId`-branding those methods lands in the [branded-ids RFC](../../proposed/architecture/2026-06-20-branded-ids.md) instead. The persistence removal stands: `has()`/`delete()` had only contract-test callers and no test-ergonomics cost to remove. +> **Implementation note (scope narrowed from the original proposal).** This RFC proposed pruning dead methods from BOTH the persistence seam (`SessionPersistence.has()`/`.delete()`) and the bash seam (`BashExecutor.get()`/`.list()`). Only the **persistence** removal shipped. The bash `get()`/`.list()` removal was reverted before merge: each is a one-line accessor over the executor's already-tracked `tasks` map, and removing them forced `dsh-tool-bash`'s tests onto a ~35-line `onTaskDone`-based completion-tracking harness to replace the one-line `ctx.bash.get(id)` lookup — the migration cost dwarfed the surface removed. Per the [AGENTS.md "RFCs are proposals, not golden truth"](../../../../AGENTS.md) principle, that friction is evidence the method earns its keep (a test harness IS a consumer that programs against the seam), so `get()`/`list()` stay. The bash-seam analysis below is retained for the record but was NOT acted on; `BashTaskId`-branding those methods lands in the [branded-ids RFC](../architecture/2026-06-20-branded-ids.md) instead. The persistence removal stands: `has()`/`delete()` had only contract-test callers and no test-ergonomics cost to remove. ## Problem diff --git a/examples/coding-agent/tests/coding-task.e2e.ts b/examples/coding-agent/tests/coding-task.e2e.ts index 4684301725..68bca5cdfa 100644 --- a/examples/coding-agent/tests/coding-task.e2e.ts +++ b/examples/coding-agent/tests/coding-task.e2e.ts @@ -4,6 +4,7 @@ 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' /** @@ -53,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) - const agent = ctx.agentLoop.create('e2e-task', { + const agent = ctx.agentLoop.create(AgentId('e2e-task'), { model: 'deepseek-v4-flash', systemPrompt: SYSTEM_PROMPT, }) diff --git a/examples/coding-agent/tests/full-loop.e2e.ts b/examples/coding-agent/tests/full-loop.e2e.ts index 93bc0b1fac..2b70d6f339 100644 --- a/examples/coding-agent/tests/full-loop.e2e.ts +++ b/examples/coding-agent/tests/full-loop.e2e.ts @@ -1,5 +1,6 @@ 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' /** @@ -20,7 +21,7 @@ afterEach(async () => { describe.skipIf(!process.env.DEEPSEEK_API_KEY)('full loop: real model + real bash tool', () => { it('runs a bash command on request and reports its output', async () => { ctx = await codingHarness(process.cwd()) - const agent = ctx.agentLoop.create('e2e-loop', { + const agent = ctx.agentLoop.create(AgentId('e2e-loop'), { model: 'deepseek-v4-flash', systemPrompt: SYSTEM_PROMPT, }) diff --git a/examples/coding-agent/tests/resume.e2e.ts b/examples/coding-agent/tests/resume.e2e.ts index cf2d910138..450938fc6d 100644 --- a/examples/coding-agent/tests/resume.e2e.ts +++ b/examples/coding-agent/tests/resume.e2e.ts @@ -4,6 +4,8 @@ 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' /** @@ -15,7 +17,7 @@ import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness. */ const SECRET = 'plum-galaxy-1791' -const SESSION_ID = 'resume-e2e-session' +const SESSION_ID = SessionId('resume-e2e-session') let ctx: Context | undefined let root: string | undefined @@ -38,7 +40,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses // log on disk survives. ctx = await codingHarness(process.cwd(), root) const first = ctx.agents.create({ - agentId: 'resume-1', + agentId: AgentId('resume-1'), sessionId: SESSION_ID, agentOptions: { model: 'deepseek-v4-flash', systemPrompt: SYSTEM_PROMPT }, }).agent as ReactLoopAgent @@ -52,7 +54,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses // run 1's exchange as conversation history. ctx = await codingHarness(process.cwd(), root) const resumed = (await ctx.agents.resume({ - agentId: 'resume-2', + agentId: AgentId('resume-2'), resumeSessionId: SESSION_ID, agentOptions: { model: 'deepseek-v4-flash', systemPrompt: SYSTEM_PROMPT }, })).agent as ReactLoopAgent diff --git a/knip.json b/knip.json index e2e82038e3..6699203570 100644 --- a/knip.json +++ b/knip.json @@ -17,6 +17,10 @@ "entry": ["tests/**/*.spec.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/util/brand": { + "project": ["src/**/*.ts"], + "ignoreDependencies": ["cordis"] + }, "packages/llm/llm-deepseek": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index df6e2285a9..05f1ed75dd 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -15,8 +15,8 @@ import { Context } from 'cordis' import z from 'schemastery' -import { BashExecutor } from '@deepseek-ai/dsh-bash' -import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead } from '@deepseek-ai/dsh-bash' +import { BashExecutor, BashTaskId } from '@deepseek-ai/dsh-bash' +import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash' import { runBash } from './run.ts' import type { RunInternals, RunningBash } from './run.ts' @@ -50,7 +50,7 @@ interface TrackedTask extends BashTask { stdoutOffset: number stderrOffset: number /** Opaque owner token from the {@link BashExecSpec} (the consumer's isolation key). */ - owner: string | undefined + owner: OwnerToken | undefined } /** @@ -67,7 +67,7 @@ export class LocalBashExecutor extends BashExecutor { maxOutputBytes: z.number().default(64_000), }) - private tasks = new Map() + private tasks = new Map() private nextTaskId = 1 /** Test seam: timer/spill knobs forwarded to runBash. */ internals: RunInternals = {} @@ -147,7 +147,7 @@ export class LocalBashExecutor extends BashExecutor { signal: spec.signal, }, this.internals) - const id = `bash-${this.nextTaskId++}` + const id = BashTaskId(`bash-${this.nextTaskId++}`) const task: TrackedTask = { id, command: spec.command, @@ -176,11 +176,11 @@ export class LocalBashExecutor extends BashExecutor { return task } - get(id: string): BashTask | undefined { + get(id: BashTaskId): BashTask | undefined { return this.tasks.get(id) } - ownerOf(id: string): string | undefined { + ownerOf(id: BashTaskId): OwnerToken | undefined { // Unknown id and known-but-ownerless both read as undefined — the consumer // treats undefined as "open" and a truly unknown id fails at readOutput/kill. return this.tasks.get(id)?.owner @@ -190,7 +190,7 @@ export class LocalBashExecutor extends BashExecutor { return [...this.tasks.values()] } - readOutput(id: string): BashTaskRead { + readOutput(id: BashTaskId): BashTaskRead { const task = this.tasks.get(id) if (!task) throw new Error(`unknown bash task "${id}"`) @@ -213,7 +213,7 @@ export class LocalBashExecutor extends BashExecutor { } } - kill(id: string): boolean { + kill(id: BashTaskId): boolean { const task = this.tasks.get(id) if (!task) throw new Error(`unknown bash task "${id}"`) if (task.status !== 'running') return false diff --git a/packages/bash/bash-local/tests/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts index 3dd7f7983a..f851e837d5 100644 --- a/packages/bash/bash-local/tests/executor.spec.ts +++ b/packages/bash/bash-local/tests/executor.spec.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import type {} from '@deepseek-ai/dsh-bash' +import { BashTaskId } from '@deepseek-ai/dsh-bash' const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-')) @@ -155,7 +155,7 @@ describe('LocalBashExecutor background tasks', () => { it('readOutput throws for unknown ids', async () => { const { bash } = await setup() - expect(() => bash.readOutput('nope')).toThrow(/unknown bash task "nope"/) + expect(() => bash.readOutput(BashTaskId('nope'))).toThrow(/unknown bash task "nope"/) }) it('kill terminates the process group and reports status killed', async () => { @@ -172,7 +172,7 @@ describe('LocalBashExecutor background tasks', () => { const task = bash.start(bash.resolve({ command: 'true' })) await task.done expect(bash.kill(task.id)).toBe(false) - expect(() => bash.kill('nope')).toThrow(/unknown bash task "nope"/) + expect(() => bash.kill(BashTaskId('nope'))).toThrow(/unknown bash task "nope"/) }) it('notifies onTaskDone listeners on completion', async () => { diff --git a/packages/bash/bash-local/tsconfig.json b/packages/bash/bash-local/tsconfig.json index 1c27a33a89..51ae489658 100644 --- a/packages/bash/bash-local/tsconfig.json +++ b/packages/bash/bash-local/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../../vendor/schemastery" }, + { + "path": "../../util/brand" + }, { "path": "../../bash/bash" } diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index ce8816dee7..6123565e7a 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -28,4 +28,4 @@ Implementations subclass `BashExecutor`, implement the abstract methods, and cal ## Vocabulary -`BashExecRequest` (command, workdir?, timeoutMs?, signal?, owner?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, owner) before execution; `owner` is optional on the request and **required-but-nullable** (`string | undefined`) on the resolved spec, so a forgotten owner is a visible `undefined` rather than a silently-absent property. `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. See `src/types.ts` for the full contracts. +`BashExecRequest` (command, workdir?, timeoutMs?, signal?, owner?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, owner) before execution; `owner` is optional on the request and **required-but-nullable** (`OwnerToken | undefined`) on the resolved spec, so a forgotten owner is a visible `undefined` rather than a silently-absent property. The task id (`BashTaskId`) and the `owner` token (`OwnerToken`) are [branded](../../util/brand) — `OwnerToken` is a DISTINCT brand from `SessionId` (the seam never imports `dsh-session`; the `dsh-tool-bash` consumer is the single boundary that casts its `SessionId` into one). `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. See `src/types.ts` for the full contracts. diff --git a/packages/bash/bash/package.json b/packages/bash/bash/package.json index 52bf80282f..66c02408d1 100644 --- a/packages/bash/bash/package.json +++ b/packages/bash/bash/package.json @@ -20,9 +20,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", "cordis": "^4.0.0-rc.6" } } diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index f4e2d964fe..01c5c081c3 100644 --- a/packages/bash/bash/src/index.ts +++ b/packages/bash/bash/src/index.ts @@ -15,8 +15,9 @@ */ import { Context, Service } from 'cordis' -import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskListener, BashTaskRead } from './types.ts' +import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId, BashTaskListener, BashTaskRead, OwnerToken } from './types.ts' +export { BashTaskId, OwnerToken } from './types.ts' export type { BashExecRequest, BashExecSpec, @@ -86,7 +87,7 @@ export abstract class BashExecutor extends Service { abstract start(spec: BashExecSpec): BashTask /** Look up a background task by id. */ - abstract get(id: string): BashTask | undefined + abstract get(id: BashTaskId): BashTask | undefined /** * The opaque OWNER token recorded for a background task at {@link start} @@ -101,19 +102,19 @@ export abstract class BashExecutor extends Service { * Storing ownership in the executor (disposed with ITS fiber) — not in the * tool plugin — is what makes ownership survive a `tool-bash` HMR reload. */ - abstract ownerOf(id: string): string | undefined + abstract ownerOf(id: BashTaskId): OwnerToken | undefined /** All tracked background tasks (insertion order). */ abstract list(): BashTask[] /** Read output produced since the previous read. Throws for unknown ids. */ - abstract readOutput(id: string): BashTaskRead + abstract readOutput(id: BashTaskId): BashTaskRead /** * Kill a running background task. Returns false when it had already * finished (no-op). Throws for unknown ids. */ - abstract kill(id: string): boolean + abstract kill(id: BashTaskId): boolean /** * Register a background-task completion listener (disposed with the diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts index e731110698..d9ab9f9b4d 100644 --- a/packages/bash/bash/src/types.ts +++ b/packages/bash/bash/src/types.ts @@ -6,6 +6,31 @@ * @module dsh-bash/types */ +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Identifies one background task within an executor (generated `bash-N`). */ +export type BashTaskId = Branded<'BashTaskId'> + +/** Brand a string as a {@link BashTaskId}. */ +export function BashTaskId(id: string): BashTaskId { + return id as BashTaskId +} + +/** + * A background task's opaque isolation key — the CONSUMER's owner identity, not + * the bash seam's. The executor stores and returns it verbatim and never + * interprets it; the access policy lives in the consumer (`dsh-tool-bash`), + * which is the single boundary that casts its own id vocabulary into one. A + * DISTINCT brand (not a `SessionId` alias) keeps the seam decoupled — a + * sandboxed/remote executor inherits no session dependency. + */ +export type OwnerToken = Branded<'OwnerToken'> + +/** Brand a string as an {@link OwnerToken}. */ +export function OwnerToken(id: string): OwnerToken { + return id as OwnerToken +} + /** * A caller's execution REQUEST: `workdir` and `timeoutMs` are optional and * filled by {@link BashExecutor.resolve} from the implementation's config. @@ -28,7 +53,7 @@ export interface BashExecRequest { * seam — that is the consumer's job). Absent for foreground runs and for an * ownerless background start (a non-agent caller). */ - owner?: string | undefined + owner?: OwnerToken | undefined } /** @@ -53,7 +78,7 @@ export interface BashExecSpec { * silently-absent property that yields an unowned (cross-session-readable) * task. `start()` stores it; `run()` (foreground) ignores it. */ - owner: string | undefined + owner: OwnerToken | undefined } /** One captured stream: the (possibly truncated) text plus recovery info. */ @@ -87,7 +112,7 @@ export type BashTaskStatus = 'running' | 'completed' | 'killed' /** A tracked background task handle. */ export interface BashTask { - readonly id: string + readonly id: BashTaskId readonly command: string status: BashTaskStatus /** Exit code once finished (null = killed by signal / still running). */ diff --git a/packages/bash/bash/tests/service.spec.ts b/packages/bash/bash/tests/service.spec.ts index 4b28bb72be..81530843ed 100644 --- a/packages/bash/bash/tests/service.spec.ts +++ b/packages/bash/bash/tests/service.spec.ts @@ -1,12 +1,12 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { BashExecutor } from '@deepseek-ai/dsh-bash' +import { BashExecutor, BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead } from '@deepseek-ai/dsh-bash' /** Minimal concrete executor: records calls, lets tests drive completions. */ class StubExecutor extends BashExecutor { - tasks = new Map() - private owners = new Map() + tasks = new Map() + private owners = new Map() resolve(request: BashExecRequest): BashExecSpec { return { @@ -32,7 +32,7 @@ class StubExecutor extends BashExecutor { start(spec: BashExecSpec): BashTask { const task: BashTask = { - id: `stub-${this.tasks.size + 1}`, + id: BashTaskId(`stub-${this.tasks.size + 1}`), command: spec.command, status: 'running', exitCode: null, @@ -44,11 +44,11 @@ class StubExecutor extends BashExecutor { return task } - get(id: string): BashTask | undefined { + get(id: BashTaskId): BashTask | undefined { return this.tasks.get(id) } - ownerOf(id: string): string | undefined { + ownerOf(id: BashTaskId): OwnerToken | undefined { return this.owners.get(id) } @@ -56,13 +56,13 @@ class StubExecutor extends BashExecutor { return [...this.tasks.values()] } - readOutput(id: string): BashTaskRead { + readOutput(id: BashTaskId): BashTaskRead { const task = this.tasks.get(id) if (!task) throw new Error(`unknown bash task "${id}"`) return { task, delta: '', lossy: false } } - kill(id: string): boolean { + kill(id: BashTaskId): boolean { const task = this.tasks.get(id) if (!task) throw new Error(`unknown bash task "${id}"`) if (task.status !== 'running') return false diff --git a/packages/bash/bash/tsconfig.json b/packages/bash/bash/tsconfig.json index 10dabc415e..0e8e8c1878 100644 --- a/packages/bash/bash/tsconfig.json +++ b/packages/bash/bash/tsconfig.json @@ -13,6 +13,9 @@ }, { "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" } ] } diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 0ea01ca50d..9ad1f9a17c 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -43,6 +43,7 @@ import { isAbsolute, resolve as resolvePath } from 'node:path' import { defineTool } from '@deepseek-ai/dsh-tools' import type { ToolCallPresentation, ToolResult, ToolResultPresentation } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' +import { BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash' import type { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash' export const name = 'tool-bash' @@ -79,11 +80,11 @@ function validateBashArgs(args: { * SchemaSpec validation (the arg-validation RFC); only the non-empty constraint, which the * DSL can't express, is left to check here. */ -function validateTaskId(value: string): string { +function validateTaskId(value: string): BashTaskId { if (value.length === 0) { throw new Error(`invalid task_id: expected a string, got ${JSON.stringify(value)}`) } - return value + return BashTaskId(value) } /** Append the truncation notice (with the full-output spill path) to a stream's text. */ @@ -279,7 +280,8 @@ export function apply(ctx: Context): void { * the conventions flag. The two are equal in production, but the header is the * canonical identity. */ - const callerToken = (exec: { agent?: Agent }): string | undefined => exec.agent?.session.header.id + const callerToken = (exec: { agent?: Agent }): OwnerToken | undefined => + exec.agent ? OwnerToken(exec.agent.session.header.id) : undefined /** * Authorize a `bash_output`/`bash_kill` call against the task's stored owner @@ -291,7 +293,7 @@ export function apply(ctx: Context): void { * `readOutput`/`kill` ("unknown bash task"). The conservative no-agent caller * (`callerToken` undefined) cannot match an owned task and is rejected. */ - const assertTaskAccess = (taskId: string, exec: { agent?: Agent }): void => { + const assertTaskAccess = (taskId: BashTaskId, exec: { agent?: Agent }): void => { const owner = ctx.bash.ownerOf(taskId) if (owner !== undefined && owner !== callerToken(exec)) { throw new Error(`task ${taskId} belongs to another session`) @@ -310,7 +312,7 @@ export function apply(ctx: Context): void { ctx.bash.onTaskDone((task) => { const ownerToken = ctx.bash.ownerOf(task.id) if (ownerToken === undefined) return - const agent = ctx.get('agents')?.list().find(a => a.session.header.id === ownerToken) + const agent = ctx.get('agents')?.list().find(a => OwnerToken(a.session.header.id) === ownerToken) if (!agent) return try { agent.inject( diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index 0ab786ca85..a67809ffee 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -5,9 +5,10 @@ import SessionStore 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 from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import { BashTaskId } from '@deepseek-ai/dsh-bash' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -73,7 +74,7 @@ describe('bash tool through the agent loop', () => { textResponse('The command printed integration-ok.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('it-fg', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('it-fg'), { model: 'mock' }) agent.send([{ type: 'text', text: 'run echo integration-ok' }]) await waitForIdle(ctx, agent) @@ -105,7 +106,7 @@ describe('bash tool through the agent loop', () => { textResponse('It failed with code 9.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('it-exit', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('it-exit'), { model: 'mock' }) agent.send([{ type: 'text', text: 'run exit 9' }]) await waitForIdle(ctx, agent) @@ -126,7 +127,7 @@ describe('bash tool through the agent loop', () => { let taskId = '' const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('it-bg', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('it-bg'), { model: 'mock' }) // Intercept the first tool result to capture the generated task id, then // rewrite the second scripted call's arguments to use it. @@ -147,7 +148,7 @@ describe('bash tool through the agent loop', () => { await waitForIdle(ctx, agent) // Wait for the background task itself (completion may race turn end). - const task = ctx.bash.get(taskId) + const task = ctx.bash.get(BashTaskId(taskId)) if (!task) throw new Error(`task ${taskId} not registered`) await task.done diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index c49410a9b2..33c392ff7e 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -4,8 +4,8 @@ import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' -import { BashExecutor } from '@deepseek-ai/dsh-bash' -import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead } from '@deepseek-ai/dsh-bash' +import { BashExecutor, BashTaskId } from '@deepseek-ai/dsh-bash' +import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' @@ -68,7 +68,7 @@ function text(result: { content: { type: string; text?: string }[] }): string { class LossyReadBashExecutor extends BashExecutor { private readonly task: BashTask = { - id: 'bash-lossy', + id: BashTaskId('bash-lossy'), command: 'fake', status: 'running', exitCode: null, @@ -94,11 +94,11 @@ class LossyReadBashExecutor extends BashExecutor { return this.task } - get(id: string): BashTask | undefined { + get(id: BashTaskId): BashTask | undefined { return id === this.task.id ? this.task : undefined } - ownerOf(): string | undefined { + ownerOf(): OwnerToken | undefined { return undefined } @@ -106,7 +106,7 @@ class LossyReadBashExecutor extends BashExecutor { return [this.task] } - readOutput(id: string): BashTaskRead { + readOutput(id: BashTaskId): BashTaskRead { if (id !== this.task.id) throw new Error(`unknown bash task "${id}"`) return { task: this.task, delta: 'tail', lossy: true } } @@ -279,7 +279,7 @@ describe('background tools', () => { it('bash_output polls incrementally and reports status', async () => { const ctx = await setup() const started = await call(ctx, 'bash', { command: 'echo first; sleep 0.3; echo second', description: 'test command', run_in_background: true }) - const id = /task (bash-\d+)/.exec(text(started))![1]! + const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!) await new Promise(resolve => setTimeout(resolve, 150)) const first = await call(ctx, 'bash_output', { task_id: id }) @@ -305,7 +305,7 @@ describe('background tools', () => { await ctx.plugin(ToolBash) const started = await call(ctx, 'bash', { command: 'for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', description: 'test command', run_in_background: true }) - const id = /task (bash-\d+)/.exec(text(started))![1]! + const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!) await ctx.bash.get(id)!.done const read = await call(ctx, 'bash_output', { task_id: id }) expect(text(read)).toContain('[some output was dropped from memory; full output: ') @@ -325,7 +325,7 @@ describe('background tools', () => { it('bash_kill stops a running task; repeat reports already-finished', async () => { const ctx = await setup() const started = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true }) - const id = /task (bash-\d+)/.exec(text(started))![1]! + const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!) const killed = await call(ctx, 'bash_kill', { task_id: id }) expect(text(killed)).toBe(`killed background task ${id}`) @@ -372,7 +372,7 @@ describe('background tools', () => { arguments: { command: 'true', description: 'test command', run_in_background: true }, agent, }) - const id = /task (bash-\d+)/.exec(text(started))![1]! + const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!) await ctx.bash.get(id)!.done expect(inject).toHaveBeenCalledTimes(1) @@ -395,7 +395,7 @@ describe('background tools', () => { arguments: { command: 'true', description: 'test command', run_in_background: true }, agent, }) - const id = /task (bash-\d+)/.exec(text(started))![1]! + const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!) await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined() }) @@ -414,7 +414,7 @@ describe('background tools', () => { arguments: { command: 'true', description: 'test command', run_in_background: true }, agent, }) - const id = /task (bash-\d+)/.exec(text(started))![1]! + const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!) await ctx.bash.get(id)!.done // notifyTaskDone caught and logged the rethrown error. expect(errorSpy).toHaveBeenCalled() @@ -440,7 +440,7 @@ describe('background tools', () => { arguments: { command: 'true', description: 'test command', run_in_background: true }, agent, }) - const id = /task (bash-\d+)/.exec(text(started))![1]! + const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!) // Unregister the agent BEFORE the task completes (simulate disconnect). unregisterFakeAgents(ctx) await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined() @@ -450,7 +450,7 @@ describe('background tools', () => { it('does not notify when no agent owned the task', async () => { const ctx = await setup() const started = await call(ctx, 'bash', { command: 'true', description: 'test command', run_in_background: true }) - const id = /task (bash-\d+)/.exec(text(started))![1]! + const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!) await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined() }) }) @@ -474,7 +474,7 @@ describe('background task ownership (cross-session isolation)', () => { const b = fakeAgent('sess-b') // Agent A starts a long-running background task. const started = await callAs(ctx, a, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true }) - const id = /task (bash-\d+)/.exec(text(started))![1]! + const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!) // Agent B (a different session token) cannot read or kill A's task. const readByB = await callAs(ctx, b, 'bash_output', { task_id: id }) @@ -498,7 +498,7 @@ describe('background task ownership (cross-session isolation)', () => { const a1 = fakeAgent('sess-shared') const a2 = fakeAgent('sess-shared') // distinct object, same token const started = await callAs(ctx, a1, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true }) - const id = /task (bash-\d+)/.exec(text(started))![1]! + const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!) const readByA2 = await callAs(ctx, a2, 'bash_output', { task_id: id }) expect(readByA2.isError).toBe(false) await callAs(ctx, a1, 'bash_kill', { task_id: id }) // cleanup @@ -508,7 +508,7 @@ describe('background task ownership (cross-session isolation)', () => { const ctx = await setup() const a = fakeAgent('sess-a') const started = await callAs(ctx, a, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true }) - const id = /task (bash-\d+)/.exec(text(started))![1]! + const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!) // A call with no exec.agent has no token → cannot prove ownership of an owned task. const read = await callAs(ctx, undefined, 'bash_output', { task_id: id }) expect(read.isError).toBe(true) @@ -520,7 +520,7 @@ describe('background task ownership (cross-session isolation)', () => { const ctx = await setup() // Started by a non-loop caller (no exec.agent) → no owner token recorded. const started = await callAs(ctx, undefined, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true }) - const id = /task (bash-\d+)/.exec(text(started))![1]! + const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!) // Any agent (and the no-agent caller) may read/kill it. const read = await callAs(ctx, fakeAgent('sess-x'), 'bash_output', { task_id: id }) expect(read.isError).toBe(false) @@ -533,7 +533,7 @@ describe('background task ownership (cross-session isolation)', () => { const a = fakeAgent('sess-a') const b = fakeAgent('sess-b') const started = await callAs(ctx, a, 'bash', { command: 'echo done', description: 'bg', run_in_background: true }) - const id = /task (bash-\d+)/.exec(text(started))![1]! + const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!) await ctx.bash.get(id)!.done // Completion does NOT clear ownership: B is still rejected, A still allowed. const readByB = await callAs(ctx, b, 'bash_output', { task_id: id }) @@ -559,7 +559,7 @@ describe('background task ownership (cross-session isolation)', () => { const a = fakeAgent('sess-a') const b = fakeAgent('sess-b') const started = await callAs(ctx, a, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true }) - const id = /task (bash-\d+)/.exec(text(started))![1]! + const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!) // Before reload: B is rejected (A owns it). expect((await callAs(ctx, b, 'bash_output', { task_id: id })).isError).toBe(true) @@ -674,7 +674,7 @@ describe('status lines', () => { it('reports kills without a recorded signal (executor raced process exit)', async () => { const ctx = await setup() const started = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true }) - const id = /task (bash-\d+)/.exec(text(started))![1]! + const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!) const task = ctx.bash.get(id)! await call(ctx, 'bash_kill', { task_id: id }) @@ -688,7 +688,7 @@ describe('status lines', () => { it('reports completed tasks with a null exit code as exit 0', async () => { const ctx = await setup() const started = await call(ctx, 'bash', { command: 'true', description: 'test command', run_in_background: true }) - const id = /task (bash-\d+)/.exec(text(started))![1]! + const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!) const task = ctx.bash.get(id)! await task.done // Defensive: completed tasks always carry an exit code in practice; the diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 5c25eb197d..90d641eeeb 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -10,8 +10,7 @@ import { Context, Service } from 'cordis' import { randomUUID } from 'node:crypto' import z from 'schemastery' -import { AgentId } from '@deepseek-ai/dsh-agent' -import type { AgentFactory, AgentHandle, AgentOptions, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent' +import type { AgentFactory, AgentHandle, AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' @@ -33,7 +32,7 @@ declare module 'cordis' { export interface Config { /** Agents created from configuration at startup. */ agents: (AgentOptions & { - id: string + id: AgentId /** * If set, the config agent RESUMES this persisted session id instead of * starting a fresh `${id}-session-`. Sourced from an env var in @@ -42,8 +41,12 @@ export interface Config { * `dsh-session-persistence` backend; the resume is deferred until that * service is available (via `ctx.inject`) and the loaded session's events * seed the live session so history continues. + * + * The schema accepts a plain string at runtime (cordis.yml values are + * untyped); the brand is compile-time only — the config format is the + * boundary where an id enters, so the TYPE declares the brand here. */ - resumeSessionId?: string + resumeSessionId?: SessionId })[] } @@ -60,14 +63,19 @@ export interface Config { export class AgentLoop extends Service implements AgentFactory { static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt'] - static Config: z = z.object({ + // The schema validates plain strings (cordis.yml config values are untyped at + // runtime); the {@link Config} TYPE declares the branded `id`/`resumeSessionId` + // because the config format is the boundary where an id enters. The brand is a + // zero-cost compile-time cast, so the runtime schema stays string-based and we + // assert the branded view once here — the single schema boundary. + static Config = z.object({ agents: z.array(z.object({ id: z.string().required(), model: z.string(), systemPrompt: z.string(), resumeSessionId: z.string(), })).default([]), - }) + }) as unknown as z constructor(ctx: Context, public config: Config) { super(ctx, 'agentLoop') @@ -118,14 +126,14 @@ export class AgentLoop extends Service implements AgentFactory { * fork seeds the new Session with the parent's event log, spawn starts * fresh; the child is returned as a regular Agent handle. */ - create(id: string, options: AgentOptions = {}): ReactLoopAgent { + create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent { this.assertAgentIdFree(id) // Config/programmatic path: prepare the session and let start() fold its // lifecycle into the agent's composite effect (so a fiber unload tears the // session + agent down as one ordered chain, capturing the loop's closing // flush). The whole effect is owned by THIS fiber; no AgentHandle is needed. - const session = this.ctx.sessions.prepare(`${id}-session-${randomUUID()}`, { meta: {} }) - const { agent } = this.start(AgentId(id), options, session) + const session = this.ctx.sessions.prepare(SessionId(`${id}-session-${randomUUID()}`), { meta: {} }) + const { agent } = this.start(id, options, session) return agent } @@ -142,7 +150,7 @@ export class AgentLoop extends Service implements AgentFactory { // live session (and lazy persistence state) that blocks reuse of that id. this.assertAgentIdFree(options.agentId) const session = this.ctx.sessions.prepare(options.sessionId, { meta: options.meta ?? {} }) - return this.startOwned(AgentId(options.agentId), options.agentOptions ?? {}, session) + return this.startOwned(options.agentId, options.agentOptions ?? {}, session) } /** @@ -191,7 +199,7 @@ export class AgentLoop extends Service implements AgentFactory { */ private async resumeWith(persistence: SessionPersistence, options: ResumeAgentOptions): Promise { this.assertAgentIdFree(options.agentId) - const { meta, events } = await persistence.load(SessionId(options.resumeSessionId)) + const { meta, events } = await persistence.load(options.resumeSessionId) // Re-check the agent id AFTER the await: the pre-load check above can go // stale while load() is pending (a concurrent resume/create may register the // same id). Re-checking immediately before prepare()/start keeps the @@ -211,7 +219,7 @@ export class AgentLoop extends Service implements AgentFactory { ...meta.parentSession !== undefined ? { parentSession: meta.parentSession } : {}, }, }) - return this.startOwned(AgentId(options.agentId), options.agentOptions ?? {}, session) + return this.startOwned(options.agentId, options.agentOptions ?? {}, session) } /** @@ -220,7 +228,7 @@ export class AgentLoop extends Service implements AgentFactory { * persistence state) behind. `register()` enforces the same uniqueness, but * only after the session has already entered the store. */ - private assertAgentIdFree(id: string): void { + private assertAgentIdFree(id: AgentId): void { if (this.ctx.agents.get(id) !== undefined) { throw new Error(`agent "${id}" is already registered`) } diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 7c46df956c..d9235cfef3 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -2,7 +2,7 @@ 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 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 from '@deepseek-ai/dsh-agent' @@ -53,7 +53,7 @@ describe('ReactLoopAgent', () => { const ctx = await harness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create('scoped', { model: 'mock' }) + agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -68,7 +68,7 @@ describe('ReactLoopAgent', () => { const ctx = await harness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create('scoped', { model: 'mock' }) + agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -83,7 +83,7 @@ describe('ReactLoopAgent', () => { const ctx = await harness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create('scoped', { model: 'mock' }) + agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -96,7 +96,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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { 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 @@ -122,7 +122,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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) // inject() is synchronous and fires a fire-and-forget flush; a rejecting // flush must be contained (logged), never thrown into the caller. @@ -135,7 +135,7 @@ 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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let flushes = 0 ctx.on('session/flush', () => { flushes += 1 }) @@ -155,7 +155,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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let flushes = 0 ctx.on('session/flush', () => { flushes += 1 }) // A session/event listener that throws on the synthetic turn/end. Append @@ -180,7 +180,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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { 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 })) @@ -199,7 +199,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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { 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. @@ -214,7 +214,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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) // steer while idle delegates to send agent.steer([{ type: 'text', text: 'steer idle' }], { source: { kind: 'plugin', plugin: 'test' } }) @@ -230,7 +230,7 @@ describe('ReactLoopAgent', () => { // Then call it twice — the second call hits the early-return branch. const ctx = new Context() await ctx.plugin(SessionStore) - const session = ctx.sessions.create('test') + const session = ctx.sessions.create(SessionId('test')) const agent = new ReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session) // Start the loop to get the disposer; the agent waits for messages @@ -249,7 +249,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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const statuses: string[] = [] ctx.on('agent/status', (subject, status) => { @@ -268,7 +268,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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) // Fresh agent is idle — whenIdle() takes the not-running fast path and // resolves without subscribing. await must not hang. @@ -279,7 +279,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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) send(agent, 'queued') let settled = false @@ -297,8 +297,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('a1', { model: 'mock' }) - const other = ctx.agentLoop.create('a2', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const other = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' }) // Drive `agent` into `running`, then await whenIdle() — it subscribes to // agent/status and resolves on the first transition out of running. @@ -333,7 +333,7 @@ describe('ReactLoopAgent', () => { await ctx.plugin(AgentRegistry) const adapter = new MockAdapter(['hang']) ctx.llm.registerAdapter(['mock'], adapter) - const session = ctx.sessions.create('bare') + const session = ctx.sessions.create(SessionId('bare')) const agent = new ReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session) const dispose = agent.start() agent.send([{ type: 'text', text: 'go' }]) @@ -357,7 +357,7 @@ describe('ReactLoopAgent', () => { const ctx = await harness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create('scoped', { model: 'mock' }) + agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -378,7 +378,7 @@ describe('ReactLoopAgent', () => { const ctx = await harness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create('scoped', { model: 'mock' }) + agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -399,7 +399,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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) ctx.on('agent/status', (_subject, status) => { if (status === 'running') throw new Error('bad running listener') }) @@ -417,7 +417,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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) ctx.on('agent/status', (_subject, status) => { if (status === 'idle') throw new Error('bad idle listener') }) @@ -434,7 +434,7 @@ describe('ReactLoopAgent', () => { it('abort() resolves reason to "aborted" when no reason provided', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: { kind: string; reason?: string }[] = [] ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index a4e9e13a1c..4a417dbdce 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -15,7 +15,7 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { TurnEndReason } 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, { AgentId } from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -56,7 +56,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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { 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 +73,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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { 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 +92,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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) // Queue work, then register a whenIdle() waiter while in the pre-step window // (status idle, hasQueued true) — it does NOT take the fast path. Then cancel. @@ -113,7 +113,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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) @@ -130,7 +130,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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) @@ -146,7 +146,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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) // First turn hangs; cancel it mid-step. send(agent, 'first') @@ -168,7 +168,7 @@ describe('Agent.cancel()', () => { it('cancel from a synchronous agent/turn-start 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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) // A turn-start listener fires BEFORE any AbortController is installed for the // step. Cancelling there must still drop the step (the turn-scoped marker, @@ -200,7 +200,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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let steps = 0 ctx.on('agent/step-start', () => { steps += 1 }) @@ -230,7 +230,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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) // setStatus('running') emits agent/status SYNCHRONOUSLY, so a running // listener can cancel in the gap between the loop's pre-step check and @@ -260,7 +260,7 @@ describe('Agent.cancel()', () => { // so whenIdle() resolves on the replacement turn's running→idle, not before. const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let replaced = false const dispose = ctx.on('agent/status', (subject, status) => { @@ -290,7 +290,7 @@ describe('Agent.cancel()', () => { // settle (the quiescence contract), not resolve before B's first event. const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { 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) @@ -310,7 +310,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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { 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 d4753432bc..8cf5bd81f8 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -4,10 +4,10 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' 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 from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -35,10 +35,10 @@ describe('config-driven session id', () => { await ctx1.plugin(SystemPrompt) await ctx1.plugin(ToolRegistry) await ctx1.plugin(AgentRegistry) - await ctx1.plugin(AgentLoop, { agents: [{ id: 'cfg', model: 'mock', systemPrompt: '' }] }) + await ctx1.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock', systemPrompt: '' }] }) await ctx1.plugin(SessionPersistenceJsonl, { root }) ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg')])) - const a1 = ctx1.agents.get('cfg') as ReactLoopAgent + const a1 = ctx1.agents.get(AgentId('cfg')) as ReactLoopAgent expect(a1.session.id).toMatch(idPattern) a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) @@ -52,10 +52,10 @@ describe('config-driven session id', () => { await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRegistry) await ctx2.plugin(AgentRegistry) - await ctx2.plugin(AgentLoop, { agents: [{ id: 'cfg', model: 'mock', systemPrompt: '' }] }) + await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock', systemPrompt: '' }] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg2')])) - const a2 = ctx2.agents.get('cfg') as ReactLoopAgent + const a2 = ctx2.agents.get(AgentId('cfg')) as ReactLoopAgent expect(a2.session.id).toMatch(idPattern) expect(a2.session.id).not.toBe(a1.session.id) a2.send([{ type: 'text', text: 'q2' }], { source: { kind: 'user' } }) @@ -78,7 +78,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 = ctx1.agents.create({ agentId: 'main', sessionId: 'sticky-1' }).agent as ReactLoopAgent + const a1 = ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sticky-1') }).agent as ReactLoopAgent a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -92,7 +92,7 @@ describe('config-driven session id', () => { await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRegistry) await ctx2.plugin(AgentRegistry) - await ctx2.plugin(AgentLoop, { agents: [{ id: 'main', model: 'mock', systemPrompt: '', resumeSessionId: 'sticky-1' }] }) + await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', systemPrompt: '', resumeSessionId: SessionId('sticky-1') }] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')])) @@ -100,7 +100,7 @@ describe('config-driven session id', () => { let resumed: ReactLoopAgent | undefined for (let i = 0; i < 50 && !resumed; i++) { await new Promise(r => setTimeout(r, 5)) - resumed = ctx2.agents.get('main') as ReactLoopAgent | undefined + resumed = ctx2.agents.get(AgentId('main')) as ReactLoopAgent | undefined } expect(resumed).toBeDefined() // The live session id IS the resumed id (NOT a fresh ${id}-session-), @@ -120,7 +120,7 @@ describe('config-driven session id', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentLoop, { agents: [{ id: 'main', model: 'mock', systemPrompt: '', resumeSessionId: 'does-not-exist' }] }) + await ctx.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', systemPrompt: '', 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 }) @@ -129,7 +129,7 @@ describe('config-driven session id', () => { // 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. await new Promise(r => setTimeout(r, 200)) - expect(ctx.agents.get('main')).toBeUndefined() + expect(ctx.agents.get(AgentId('main'))).toBeUndefined() 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/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 96c061d2dd..12036d7aec 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -4,7 +4,7 @@ import LlmService, { CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -43,7 +43,7 @@ describe('turn boundary listener throws (handled in-turn, loop survives)', () => // The second turn should proceed normally and consume the first script entry. const adapter = new MockAdapter([textResponse('turn 2')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let threwOnce = false ctx.on('agent/turn-start', () => { @@ -73,7 +73,7 @@ describe('turn boundary listener throws (handled in-turn, loop survives)', () => it('a throwing agent/turn-end listener surfaces via agent/error and the loop survives', async () => { const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let threwOnce = false ctx.on('agent/turn-end', () => { @@ -107,7 +107,7 @@ describe('turn boundary listener throws (handled in-turn, loop survives)', () => // driver survives. This is the ONLY path that reaches the backstop. const adapter = new MockAdapter([textResponse('turn 2')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { 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 })) @@ -149,7 +149,7 @@ describe('tool JSON parse', () => { return [{ type: 'text', text: typeof args === 'string' ? `raw: ${args}` : JSON.stringify(args) }] }, })) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) send(agent, 'use tool') await waitForIdle(ctx, agent) @@ -182,7 +182,7 @@ describe('tool JSON parse', () => { return [{ type: 'text', text: 'ran with empty args' }] }, })) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) send(agent, 'use tool') await waitForIdle(ctx, agent) @@ -195,7 +195,7 @@ describe('toError normalization', () => { it('normalizes non-Error throws from turn-start listeners via toError', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let threwOnce = false ctx.on('agent/turn-start', () => { @@ -221,7 +221,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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let threwOnce = false ctx.on('agent/request', async (_agent, _turn, _step, _options, _next) => { @@ -249,7 +249,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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let threwOnce = false ctx.on('agent/request', async (_agent, _turn, _step, _options, next) => { @@ -283,7 +283,7 @@ describe('disposed vs aborted branching', () => { const ctx = await harness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create('scoped', { model: 'mock' }) + agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -311,7 +311,7 @@ describe('structured tool error propagation (the runtime-validation RFC, part 2) textResponse('done'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) ctx.tools.register(defineTool({ name: 'boom', description: 'always fails', diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index f004e02f91..cd912f39cd 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm' -import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session' +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 from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -44,7 +44,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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const order: string[] = [] for (const name of ['agent/turn-start', 'agent/step-start', 'agent/step-end', 'agent/turn-end'] as const) { @@ -85,7 +85,7 @@ describe('agent loop', () => { return [{ type: 'text', text: `echo: ${args.text}` }] }, })) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) send(agent, 'use the tool') await waitForIdle(ctx, agent) @@ -120,7 +120,7 @@ describe('agent loop', () => { return [] }, })) - const agent = ctx.agentLoop.create('a1', { model: 'mock', systemPrompt: 'Agent-specific suffix.' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock', systemPrompt: 'Agent-specific suffix.' }) send(agent, 'hi') await waitForIdle(ctx, agent) @@ -133,7 +133,7 @@ describe('agent loop', () => { it('records raw chunks for replay and emits agent/stream-chunk', async () => { const adapter = new MockAdapter([textResponse('abc')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const streamed: StreamChunk[] = [] ctx.on('agent/stream-chunk', (_agent, _turn, _step, chunk) => void streamed.push(chunk)) @@ -161,7 +161,7 @@ describe('agent loop', () => { ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) ctx.tools.register(defineTool({ name: 'slow', description: '', @@ -193,7 +193,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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) agent.steer([{ type: 'text', text: 'hello' }]) await waitForIdle(ctx, agent) @@ -203,7 +203,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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { 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 @@ -230,7 +230,7 @@ describe('agent loop', () => { textResponse('done'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) // A tool that injects mid-execution: at this point the agent is running, so // inject must append the context/message into the ALREADY-open turn rather // than wrap it in its own one-shot turn. @@ -264,7 +264,7 @@ describe('agent loop', () => { textResponse('step 3'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let steps = 0 ctx.on('agent/step-end', () => void steps++) @@ -290,7 +290,7 @@ describe('agent loop', () => { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) ctx.on('agent/turn-continuation', async () => false as const) @@ -306,7 +306,7 @@ describe('agent loop', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) ctx.llm.registerAdapter(['other-model'], adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) ctx.on('agent/request', async (_agent, _turn, _step, options, next) => { options.model = 'other-model' @@ -321,7 +321,7 @@ describe('agent loop', () => { it('abort() mid-stream ends the turn with reason aborted', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) @@ -341,7 +341,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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) @@ -366,7 +366,7 @@ describe('agent loop', () => { textResponse('second half'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let steps = 0 ctx.on('agent/step-end', () => void steps++) @@ -397,7 +397,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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) @@ -430,7 +430,7 @@ describe('agent loop', () => { return [{ type: 'text', text: 'should not run' }] }, })) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) @@ -461,7 +461,7 @@ describe('agent loop', () => { expect(message.content).toEqual([{ type: 'text', text: 'partial text' }]) return next() }) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -488,7 +488,7 @@ describe('agent loop', () => { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let threw = false ctx.on('agent/step-end', () => { if (!threw) { threw = true; throw new Error('bad step-end listener') } @@ -505,7 +505,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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const turns: number[] = [] ctx.on('agent/turn-start', (_agent, turn) => void turns.push(turn)) @@ -530,7 +530,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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let flushed = 0 let flushedBeforeIdle = false @@ -550,7 +550,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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const errors: Error[] = [] const reasons: TurnEndReason[] = [] @@ -572,10 +572,10 @@ describe('agent loop', () => { let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create('scoped', { model: 'mock' }) + agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) - expect(ctx.agents.get('scoped')).toBe(agent) + expect(ctx.agents.get(AgentId('scoped'))).toBe(agent) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') @@ -584,7 +584,7 @@ describe('agent loop', () => { await agent.done expect(agent.status).toBe('disposed') - expect(ctx.agents.get('scoped')).toBeUndefined() + expect(ctx.agents.get(AgentId('scoped'))).toBeUndefined() expect(() => { send(agent, 'too late') }).toThrow('disposed') }) @@ -597,11 +597,11 @@ describe('agent loop', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { - agents: [{ id: 'config-agent', model: 'mock', systemPrompt: 'Config prompt' }], + agents: [{ id: AgentId('config-agent'), model: 'mock', systemPrompt: 'Config prompt' }], }) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agents.get('config-agent')! as ReactLoopAgent + const agent = ctx.agents.get(AgentId('config-agent'))! as ReactLoopAgent expect(agent).toBeDefined() expect(agent.id).toBe('config-agent') expect(agent.options.model).toBe('mock') @@ -626,11 +626,11 @@ describe('agent loop', () => { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) send(agent, 'run') await waitForIdle(ctx, agent) - const replayed = ctx.sessions.create('replayed', { seed: [...agent.session.events] }) + const replayed = ctx.sessions.create(SessionId('replayed'), { seed: [...agent.session.events] }) expect(replayed.deriveMessages()).toEqual(agent.session.deriveMessages()) // event-by-event identity of types expect(replayed.events.map(e => e.type)).toEqual( diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index da457f4ddc..1e603a1cc4 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -17,7 +17,7 @@ import { LlmAdapter } from '@deepseek-ai/dsh-llm' import SessionStore 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, { AgentId } from '@deepseek-ai/dsh-agent' import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import fc from 'fast-check' @@ -95,7 +95,7 @@ describe('agent loop scheduling properties', () => { async (texts) => { const ctx = await harness() try { - const agent = ctx.agentLoop.create('a', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a'), { 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. @@ -120,7 +120,7 @@ describe('agent loop scheduling properties', () => { async (texts) => { const ctx = await harness() try { - const agent = ctx.agentLoop.create('a', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a'), { model: 'mock' }) for (const text of texts) { const idle = nextIdle(ctx, agent) agent.send([{ type: 'text', text }]) @@ -145,7 +145,7 @@ describe('agent loop scheduling properties', () => { async (steps) => { const ctx = await harness() try { - const agent = ctx.agentLoop.create('a', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a'), { model: 'mock' }) // Capture an idle waiter before EACH send; the last one is guaranteed // to resolve because the final send always triggers (or joins) a turn // that ends idle. Awaiting an already-resolved waiter is a no-op, so a diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index bc655a32f2..6192396cab 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -8,7 +8,7 @@ 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 from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -43,7 +43,7 @@ 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 } = ctx.agents.create({ agentId: 'a1', sessionId: 'custom-session', meta: { cwd: '/w' } }) + const { agent } = ctx.agents.create({ agentId: AgentId('a1'), 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() @@ -52,18 +52,18 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { it('createAgent rejects a duplicate agent id BEFORE creating the session (no orphan)', async () => { const adapter = new MockAdapter([textResponse('hi')]) const { ctx } = await persistentHarness(adapter) - ctx.agents.create({ agentId: 'dup', sessionId: 'sess-a' }) + 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. - expect(() => ctx.agents.create({ agentId: 'dup', sessionId: 'sess-b' })).toThrow(/already registered/) - expect(ctx.sessions.get('sess-b')).toBeUndefined() + expect(() => ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-b') })).toThrow(/already registered/) + expect(ctx.sessions.get(SessionId('sess-b'))).toBeUndefined() 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 } = ctx.agents.create({ agentId: 'a-nometa', sessionId: 'nometa-session' }) + const { agent } = ctx.agents.create({ agentId: AgentId('a-nometa'), sessionId: SessionId('nometa-session') }) expect(agent.session.id).toBe('nometa-session') expect(agent.session.header.cwd).toBeUndefined() await ctx.fiber.dispose() @@ -73,7 +73,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 = ctx1.agents.create({ agentId: 'm', sessionId: 'nocwd-sess' }).agent as ReactLoopAgent + const a1 = ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('nocwd-sess') }).agent as ReactLoopAgent a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -89,7 +89,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: 'm', resumeSessionId: 'nocwd-sess' })).agent as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent expect(a2.session.header.cwd).toBeUndefined() await ctx2.fiber.dispose() }) @@ -104,7 +104,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { ] const adapter1 = new MockAdapter([textResponse('a')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const forked = ctx1.sessions.create('forked-sess', { seed, meta: { cwd: '/w', parentSession: SessionId('parent-sess') } }) + const forked = ctx1.sessions.create(SessionId('forked-sess'), { seed, meta: { cwd: '/w', parentSession: SessionId('parent-sess') } }) await ctx1.parallel('session/flush', forked) await ctx1.fiber.dispose() @@ -120,7 +120,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: 'm', resumeSessionId: 'forked-sess' })).agent as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('forked-sess') })).agent as ReactLoopAgent expect(a2.session.header.parentSession).toBe('parent-sess') expect(a2.session.header.cwd).toBe('/w') await ctx2.fiber.dispose() @@ -133,7 +133,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // disk, since a crash before the next turn would otherwise lose it. const adapter1 = new MockAdapter([textResponse('answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'inject-sess', meta: { cwd: '/w' } }).agent as ReactLoopAgent + const a1 = ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } }).agent as ReactLoopAgent 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' } }) @@ -158,7 +158,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // drop it on reload (the bug this guards). const adapter1 = new MockAdapter([textResponse('answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'inject-sess', meta: { cwd: '/w' } }).agent as ReactLoopAgent + const a1 = ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } }).agent as ReactLoopAgent 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' } }) @@ -176,7 +176,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: 'm', resumeSessionId: 'inject-sess' })).agent as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('inject-sess') })).agent as ReactLoopAgent const flat = JSON.stringify(a2.session.deriveMessages()) expect(flat).toContain('background task 42 finished') await ctx2.fiber.dispose() @@ -186,7 +186,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 = ctx1.agents.create({ agentId: 'main', sessionId: 'sess-resume', meta: { cwd: '/w' } }).agent as ReactLoopAgent + const a1 = ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } }).agent as ReactLoopAgent a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) const events1 = [...a1.session.events] @@ -206,7 +206,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: 'main', resumeSessionId: 'sess-resume' })).agent as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ agentId: AgentId('main'), resumeSessionId: SessionId('sess-resume') })).agent as ReactLoopAgent // The resumed session carries the prior history… expect(a2.session.id).toBe('sess-resume') expect(a2.session.events.length).toBe(events1.length) @@ -234,7 +234,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) - await expect(ctx.agents.resume({ agentId: 'm', resumeSessionId: 'nope' })) + await expect(ctx.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('nope') })) .rejects.toThrow(/session persistence is not configured/) await ctx.fiber.dispose() }) diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index 8fc375457a..fa6a560c7f 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -55,7 +55,7 @@ describe('HIGH: session log records what agent/step-result actually produced', ( return [{ type: 'text', text: 'ran' }] }, })) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) // Plugin rewrites the message: replaces the text AND adds a tool call. let rewritten = false @@ -106,7 +106,7 @@ describe('HIGH: abort during tool execution ends the turn', () => { ]) const ctx = await harness(adapter) const executed: string[] = [] - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) ctx.tools.register(defineTool({ name: 'aborter', description: '', @@ -154,7 +154,7 @@ describe('HIGH: steering from late extension points is never stranded', () => { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let steeredOnce = false ctx.on('agent/step-end', () => { @@ -176,7 +176,7 @@ describe('HIGH: steering from late extension points is never stranded', () => { textResponse('continued because of steering'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let steeredOnce = false ctx.on('agent/turn-continuation', async (_agent, _turn, _decision, next) => { @@ -198,7 +198,7 @@ describe('HIGH: steering from late extension points is never stranded', () => { it('steer() from an agent/turn-end 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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let steeredOnce = false ctx.on('agent/turn-end', () => { @@ -223,7 +223,7 @@ describe('HIGH: 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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -241,7 +241,7 @@ describe('HIGH: 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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let threwOnce = false ctx.on('agent/turn-continuation', async (): Promise => { @@ -269,7 +269,7 @@ describe('HIGH: 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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let rejectedOnce = false ctx.on('session/flush', async () => { @@ -299,7 +299,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => { let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create('scoped', { model: 'mock' }) + agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) const statuses: string[] = [] @@ -322,7 +322,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => { let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create('scoped', { model: 'mock' }) + agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) ctx.on('agent/status', (_agent, status) => { @@ -335,7 +335,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => { await agent.done // must not hang expect(agent.status).toBe('disposed') - expect(ctx.agents.get('scoped')).toBeUndefined() // unregistered despite the throw + expect(ctx.agents.get(AgentId('scoped'))).toBeUndefined() // unregistered despite the throw }) }) @@ -354,7 +354,7 @@ describe('MEDIUM: misc registry and config fixes', () => { 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('a1', {}) // no model + const agent = ctx.agentLoop.create(AgentId('a1'), {}) // no model const errors: Error[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) @@ -369,7 +369,7 @@ describe('MEDIUM: misc registry and config fixes', () => { 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('a1', {}) // no model — router plugin decides + const agent = ctx.agentLoop.create(AgentId('a1'), {}) // no model — router plugin decides ctx.on('agent/request', async (_agent, _turn, _step, options, next) => { options.model = 'mock' @@ -385,7 +385,7 @@ describe('MEDIUM: misc registry and config fixes', () => { it('agent/queued carries the resolved source; agent/steering carries its source', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) ctx.tools.register(defineTool({ name: 'noop', description: '', @@ -414,7 +414,7 @@ describe('MEDIUM: turn numbering continues across seeded (forked) 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('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -429,7 +429,7 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', () await ctx2.plugin(AgentLoop, { agents: [] }) ctx2.llm.registerAdapter(['mock'], second) - const seeded = ctx2.sessions.create('forked', { seed: [...agent.session.events] }) + const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] }) const forked = new ReactLoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded) ctx2.effect(() => forked.start()) @@ -475,7 +475,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete ] const adapter = new MockAdapter([errorStream]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a-finish-error', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a-finish-error'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) @@ -498,7 +498,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete ] const adapter = new MockAdapter([abortedStream]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a-finish-aborted', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a-finish-aborted'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) @@ -516,7 +516,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete ] const adapter = new MockAdapter([errorStream]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a-finish-error-nocode', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a-finish-error-nocode'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) @@ -532,7 +532,7 @@ describe('P1-6: step/start is appended before agent/step-start is emitted', () = it('a step-start listener sees the step/start event already in session.events', async () => { const adapter = new MockAdapter([textResponse('done')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a-step-order', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a-step-order'), { model: 'mock' }) // Capture, at the moment agent/step-start fires, whether the matching // step/start event is already in the log (append-before-emit, the event-sourcing RFC). @@ -591,7 +591,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar it('a throwing agent/turn-start listener still closes the turn with exactly one error and one turn/end, no step', async () => { const adapter = new MockAdapter([textResponse('never reached')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create('a-turnstart', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a-turnstart'), { model: 'mock' }) let threw = false ctx.on('agent/turn-start', () => { if (!threw) { threw = true; throw new Error('boom turn-start') } }) @@ -613,7 +613,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar it('a throwing agent/step-start listener closes the open step then the turn (step/end before turn/end)', async () => { const adapter = new MockAdapter([textResponse('never reached')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create('a-stepstart', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a-stepstart'), { model: 'mock' }) let threw = false ctx.on('agent/step-start', () => { if (!threw) { threw = true; throw new Error('boom step-start') } }) @@ -642,7 +642,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar 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('a-errorlistener', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a-errorlistener'), { model: 'mock' }) let threw = false ctx.on('agent/error', () => { if (!threw) { threw = true; throw new Error('boom error-listener') } }) @@ -675,7 +675,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar const ctx = await balancedHarness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create('a-dispose', { model: 'mock' }) + agent = inner.agentLoop.create(AgentId('a-dispose'), { model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -706,7 +706,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar const ctx = await balancedHarness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create('a-dispose-emit-throw', { model: 'mock' }) + agent = inner.agentLoop.create(AgentId('a-dispose-emit-throw'), { model: 'mock' }) }, { inject: ['agentLoop'] })) // The FIRST agent/turn-end emit throws (the disposal-driven turn end). @@ -749,7 +749,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar // subscriber.) const adapter = new MockAdapter([textResponse('turn 2')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a-preturn', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a-preturn'), { model: 'mock' }) let threw = false ctx.on('session/event', (_session, event) => { @@ -788,7 +788,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar // surfaced via agent/error instead, and the log's last event is turn/end. const adapter = new MockAdapter([textResponse('done'), textResponse('next ok')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create('a-tend', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a-tend'), { model: 'mock' }) let threw = false ctx.on('agent/turn-end', () => { if (!threw) { threw = true; throw new Error('boom turn-end') } }) @@ -820,7 +820,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar // swallowed the throw in the normal (no-tool, no-steering) path. const adapter = new MockAdapter([textResponse('all good'), textResponse('turn 2 ok')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create('a-stepend-throw', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a-stepend-throw'), { model: 'mock' }) let threw = false ctx.on('agent/step-end', () => { if (!threw) { threw = true; throw new Error('boom step-end') } }) @@ -862,7 +862,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider down' } }] const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create('a-double', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a-double'), { model: 'mock' }) let threw = false ctx.on('agent/turn-end', () => { if (!threw) { threw = true; throw new Error('boom turn-end') } }) @@ -897,7 +897,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider down' } }] const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a-errthrow', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a-errthrow'), { model: 'mock' }) let threw = false ctx.on('session/event', (_s, event) => { @@ -930,7 +930,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar // throw is contained + surfaced via failTurn, so turn/end is still appended. const adapter = new MockAdapter([textResponse('never reached')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a-stependthrow', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a-stependthrow'), { model: 'mock' }) // Open a step, then make the agent/step-start emit throw (boundary throw → // outer catch → closeStep during finalization). @@ -968,7 +968,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar // what throws.) const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a-turnendappend', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a-turnendappend'), { model: 'mock' }) let threw = false ctx.on('session/event', (_s, event) => { @@ -1014,7 +1014,7 @@ describe('P1-7: tool/result is logged under the originating call.id, not result. return Promise.resolve({ callId: CallId('wrong-proxy-id'), content: [{ type: 'text', text: 'ok' }], isError: false }) }, { prepend: true }) - const agent = ctx.agentLoop.create('a-callid', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a-callid'), { model: 'mock' }) send(agent, 'use tool') await waitForIdle(ctx, agent) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index df4163670d..8e9d41855c 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -9,7 +9,7 @@ Tracks live agents so UI, hook, and orchestrator plugins can find them without i ### Public API - `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber. -- `ctx.agents.get(id: string): Agent | undefined` +- `ctx.agents.get(id: AgentId): Agent | undefined` - `ctx.agents.list(): Agent[]` #### Factory seam (creation) diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index a215f35fb3..408fd2ae69 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -20,11 +20,13 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@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.6" }, "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.6" diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index cd66156052..158946178c 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -7,7 +7,7 @@ import { Context, Service } from 'cordis' import type { SessionId } from '@deepseek-ai/dsh-session' -import type { Agent, AgentOptions } from './types.ts' +import type { Agent, AgentId, AgentOptions } from './types.ts' export * from './types.ts' @@ -26,9 +26,9 @@ declare module 'cordis' { */ export interface CreateAgentOptions { /** The agent's id (the registry handle). */ - agentId: string + agentId: AgentId /** The live session's id (NOT derived from agentId). */ - sessionId: string + sessionId: SessionId /** * Session creation metadata: validated absolute `cwd` and `parentSession` * fork lineage. Mirrors the `cwd`/`parentSession` fields of @@ -47,9 +47,9 @@ export interface CreateAgentOptions { */ export interface ResumeAgentOptions { /** The agent's id (the registry handle). */ - agentId: string + agentId: AgentId /** The persisted session id to load and resume on. */ - resumeSessionId: string + resumeSessionId: SessionId /** Per-agent options (model, system prompt). */ agentOptions?: AgentOptions } @@ -103,7 +103,7 @@ const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plug * {@link setFactory}. */ export class AgentRegistry extends Service { - private store = new Map() + private store = new Map() private factory: AgentFactory | undefined constructor(ctx: Context) { @@ -188,7 +188,7 @@ export class AgentRegistry extends Service { return () => void dispose() } - get(id: string): Agent | undefined { + get(id: AgentId): Agent | undefined { return this.store.get(id) } diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index d6b35b97a1..ef20bf2705 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -9,7 +9,8 @@ * @module @deepseek-ai/dsh-agent/types */ -import type { Branded, ContentBlock, GenerateOptions, Message, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { Branded } from '@deepseek-ai/dsh-brand' +import type { ContentBlock, GenerateOptions, Message, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm' /** Identifies one live agent in the registry. */ export type AgentId = Branded<'AgentId'> diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 98f072ab10..ff952aee4f 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -32,12 +32,12 @@ describe('AgentRegistry', () => { const agent = stubAgent('a1') const dispose = ctx.agents.register(agent) expect(created).toEqual(['a1']) - expect(ctx.agents.get('a1')).toBe(agent) + expect(ctx.agents.get(AgentId('a1'))).toBe(agent) expect(ctx.agents.list()).toEqual([agent]) dispose() expect(disposed).toEqual(['a1']) - expect(ctx.agents.get('a1')).toBeUndefined() + expect(ctx.agents.get(AgentId('a1'))).toBeUndefined() }) it('rejects duplicate ids and unregisters on fiber dispose (HMR safety)', async () => { @@ -66,14 +66,14 @@ describe('AgentRegistry', () => { // The throwing emit must roll the entry back, not leak it. expect(() => ctx.agents.register(stubAgent('main'))).toThrow('boom created listener') - expect(ctx.agents.get('main')).toBeUndefined() // rolled back, not leaked + expect(ctx.agents.get(AgentId('main'))).toBeUndefined() // rolled back, not leaked // A subsequent listener-free register of the SAME id succeeds and is // tracked exactly once (the duplicate-id check is not wedged). const dispose = ctx.agents.register(stubAgent('main')) expect(ctx.agents.list().map(a => a.id)).toEqual(['main']) dispose() - expect(ctx.agents.get('main')).toBeUndefined() + expect(ctx.agents.get(AgentId('main'))).toBeUndefined() }) }) @@ -97,8 +97,8 @@ describe('AgentRegistry factory seam', () => { it('create()/resume() throw when no factory is registered', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) - expect(() => ctx.agents.create({ agentId: 'a', sessionId: 's' })).toThrow(/no agent factory/) - await expect(ctx.agents.resume({ agentId: 'a', resumeSessionId: 's' })).rejects.toThrow(/no agent factory/) + expect(() => ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).toThrow(/no agent factory/) + await expect(ctx.agents.resume({ agentId: AgentId('a'), resumeSessionId: SessionId('s') })).rejects.toThrow(/no agent factory/) }) it('setFactory registers a factory; create/resume delegate to it', async () => { @@ -107,13 +107,13 @@ describe('AgentRegistry factory seam', () => { const { factory, calls } = stubFactory() ctx.agents.setFactory(factory) - const created = ctx.agents.create({ agentId: 'c1', sessionId: 'sess-1', meta: { cwd: '/w' } }) + const created = ctx.agents.create({ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } }) expect(created.agent.id).toBe('c1') - expect(calls.create).toEqual([{ agentId: 'c1', sessionId: 'sess-1', meta: { cwd: '/w' } }]) + expect(calls.create).toEqual([{ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } }]) - const resumed = await ctx.agents.resume({ agentId: 'r1', resumeSessionId: 'old-sess' }) + const resumed = await ctx.agents.resume({ agentId: AgentId('r1'), resumeSessionId: SessionId('old-sess') }) expect(resumed.agent.id).toBe('r1') - expect(calls.resume).toEqual([{ agentId: 'r1', resumeSessionId: 'old-sess' }]) + expect(calls.resume).toEqual([{ agentId: AgentId('r1'), resumeSessionId: SessionId('old-sess') }]) }) it('setFactory rejects a second factory', async () => { @@ -130,10 +130,10 @@ describe('AgentRegistry factory seam', () => { const fiber = await ctx.plugin(Object.assign((inner: Context) => { dispose = inner.agents.setFactory(stubFactory().factory) }, { inject: ['agents'] })) - expect(() => ctx.agents.create({ agentId: 'a', sessionId: 's' })).not.toThrow() + expect(() => ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).not.toThrow() void dispose await fiber.dispose() // factory slot cleared → create throws again - expect(() => ctx.agents.create({ agentId: 'a2', sessionId: 's2' })).toThrow(/no agent factory/) + expect(() => ctx.agents.create({ agentId: AgentId('a2'), sessionId: SessionId('s2') })).toThrow(/no agent factory/) }) }) diff --git a/packages/core/agent/tsconfig.json b/packages/core/agent/tsconfig.json index e7d274f2cd..7a8eaa6e17 100644 --- a/packages/core/agent/tsconfig.json +++ b/packages/core/agent/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../util/brand" + }, { "path": "../../llm/llm" }, diff --git a/packages/core/session/README.md b/packages/core/session/README.md index dabe316a28..8c2f62cc1b 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -8,8 +8,8 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall ### Public API -- `ctx.sessions.create(id?: string, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log; `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage) as the immutable `SessionHeader`. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` to preserve it. Disposed with the calling fiber. -- `ctx.sessions.get(id: string): Session | undefined` +- `ctx.sessions.create(id?: SessionId, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log; `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage) as the immutable `SessionHeader`. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` to preserve it. Disposed with the calling fiber. +- `ctx.sessions.get(id: SessionId): Session | undefined` - `ctx.sessions.list(): Session[]` #### Advanced: ordered-teardown lifecycle primitives diff --git a/packages/core/session/package.json b/packages/core/session/package.json index f4aa5839bc..8bd0d98abe 100644 --- a/packages/core/session/package.json +++ b/packages/core/session/package.json @@ -20,10 +20,12 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "cordis": "^4.0.0-rc.6" } diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 65fbe01015..f86916d37f 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -220,7 +220,7 @@ export class Session { * subscribe to `session/event` and flush on `session/flush` / dispose. */ export class SessionStore extends Service { - private store = new Map() + private store = new Map() private counter = 0 constructor(ctx: Context) { @@ -244,7 +244,7 @@ export class SessionStore extends Service { * @throws if a session with `id` already exists, or if `meta.cwd` is a * non-absolute path (storage backends key directories off it). */ - create(id?: string, options?: CreateSessionOptions): Session { + create(id?: SessionId, options?: CreateSessionOptions): Session { const session = this.prepare(id, options) // Single effect owned by the calling fiber. Yield the detach BEFORE // announcing so a throwing `session/created` listener rolls the attach back @@ -269,7 +269,7 @@ export class SessionStore extends Service { * @throws if a session with `id` already exists, or if `meta.cwd` is a * non-absolute path. */ - prepare(id?: string, options?: CreateSessionOptions): Session { + prepare(id?: SessionId, options?: CreateSessionOptions): Session { const sessionId = SessionId(id ?? `session-${++this.counter}`) if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`) const cwd = options?.meta?.cwd @@ -321,7 +321,7 @@ export class SessionStore extends Service { this.ctx.emit('session/created', session) } - get(id: string): Session | undefined { + get(id: SessionId): Session | undefined { return this.store.get(id) } diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index cd1605d93a..4b7334b207 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -1,4 +1,5 @@ -import type { Branded, CallId, ContentBlock, MessageSource, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm' +import type { Branded } from '@deepseek-ai/dsh-brand' +import type { CallId, ContentBlock, MessageSource, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm' /** Identifies one session in the store (and its persistence artifacts). */ export type SessionId = Branded<'SessionId'> diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 593eed36c0..075d106948 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -213,11 +213,11 @@ describe('SessionStore', () => { it('rejects duplicate ids and supports seeding', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - const a = ctx.sessions.create('fixed') - expect(() => ctx.sessions.create('fixed')).toThrow('already exists') + const a = ctx.sessions.create(SessionId('fixed')) + expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('already exists') a.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }) - const forked = ctx.sessions.create('fork', { seed: [...a.events] }) + const forked = ctx.sessions.create(SessionId('fork'), { seed: [...a.events] }) expect(forked.deriveMessages()).toEqual(a.deriveMessages()) }) @@ -228,11 +228,11 @@ describe('SessionStore', () => { // the REAL session, breaking the store-uniqueness invariant. const ctx = new Context() await ctx.plugin(SessionStore) - const stale = ctx.sessions.prepare('racy') - const live = ctx.sessions.create('racy') + const stale = ctx.sessions.prepare(SessionId('racy')) + const live = ctx.sessions.create(SessionId('racy')) expect(() => ctx.sessions.enter(stale)).toThrow(/already exists/) // The live session is intact and still the store entry. - expect(ctx.sessions.get('racy')).toBe(live) + expect(ctx.sessions.get(SessionId('racy'))).toBe(live) }) it('prepare() + enter() + announce() register a session and emit session/created', async () => { @@ -241,24 +241,24 @@ describe('SessionStore', () => { const created: Session[] = [] ctx.on('session/created', session => void created.push(session)) - const session = ctx.sessions.prepare('lifecycle') + const session = ctx.sessions.prepare(SessionId('lifecycle')) // prepare alone does NOT enter the store. - expect(ctx.sessions.get('lifecycle')).toBeUndefined() + expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined() const detach = ctx.sessions.enter(session) - expect(ctx.sessions.get('lifecycle')).toBe(session) + expect(ctx.sessions.get(SessionId('lifecycle'))).toBe(session) // enter does NOT announce. expect(created).toEqual([]) ctx.sessions.announce(session) expect(created).toEqual([session]) // The detach disposer removes the entry + stops notification. detach() - expect(ctx.sessions.get('lifecycle')).toBeUndefined() + expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined() }) it('synthesizes a minimal v1 header for a bare-created session', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - const session = ctx.sessions.create('plain') + const session = ctx.sessions.create(SessionId('plain')) expect(session.header).toMatchObject({ version: 1, id: 'plain' }) expect(typeof session.header.createdAt).toBe('number') expect(session.header.cwd).toBeUndefined() @@ -268,7 +268,7 @@ describe('SessionStore', () => { it('attaches cwd and parentSession from meta to the header', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - const session = ctx.sessions.create('child', { + const session = ctx.sessions.create(SessionId('child'), { meta: { cwd: '/work/project', parentSession: SessionId('parent') }, }) expect(session.header).toMatchObject({ @@ -282,10 +282,10 @@ describe('SessionStore', () => { it('rejects a non-absolute meta.cwd', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - expect(() => ctx.sessions.create('rel', { meta: { cwd: 'relative/path' } })) + expect(() => ctx.sessions.create(SessionId('rel'), { meta: { cwd: 'relative/path' } })) .toThrow(/cwd must be an absolute path/) // the rejected session was not registered - expect(ctx.sessions.get('rel')).toBeUndefined() + expect(ctx.sessions.get(SessionId('rel'))).toBeUndefined() }) it('a bare Session() constructed without the store still exposes a v1 header', () => { @@ -300,15 +300,15 @@ describe('SessionStore', () => { let session!: Session const fiber = await ctx.plugin(Object.assign((inner: Context) => { - session = inner.sessions.create('scoped') + session = inner.sessions.create(SessionId('scoped')) }, { inject: ['sessions'] })) - expect(ctx.sessions.get('scoped')).toBe(session) + expect(ctx.sessions.get(SessionId('scoped'))).toBe(session) let observed = 0 ctx.on('session/event', () => void observed++) await fiber.dispose() - expect(ctx.sessions.get('scoped')).toBeUndefined() + expect(ctx.sessions.get(SessionId('scoped'))).toBeUndefined() session.append('user/message', { content: [{ type: 'text', text: 'late' }], source: { kind: 'user' } }) expect(observed).toBe(0) }) @@ -323,15 +323,15 @@ describe('SessionStore', () => { }) // The throwing emit must roll the store entry back, not leak it. - expect(() => ctx.sessions.create('fixed')).toThrow('boom created listener') - expect(ctx.sessions.get('fixed')).toBeUndefined() // rolled back, not leaked + expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('boom created listener') + expect(ctx.sessions.get(SessionId('fixed'))).toBeUndefined() // rolled back, not leaked // A subsequent create of the SAME id succeeds (the already-exists check is // not wedged) and its onAppend is correctly wired (events observable). const events: SessionEvent[] = [] ctx.on('session/event', (_session, event) => void events.push(event)) - const session = ctx.sessions.create('fixed') - expect(ctx.sessions.get('fixed')).toBe(session) + const session = ctx.sessions.create(SessionId('fixed')) + expect(ctx.sessions.get(SessionId('fixed'))).toBe(session) session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }) expect(events).toHaveLength(1) }) diff --git a/packages/core/session/tsconfig.json b/packages/core/session/tsconfig.json index 3423a0e06c..ca6113bb3f 100644 --- a/packages/core/session/tsconfig.json +++ b/packages/core/session/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../util/brand" + }, { "path": "../../llm/llm" } diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 38b05dc007..e15cce8252 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -14,6 +14,7 @@ import { stream as piStream } from '@earendil-works/pi-ai' import type { Model } from '@earendil-works/pi-ai' import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' +import { CallId } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk, ToolSchema } from '@deepseek-ai/dsh-llm' import { toPiContext, toStreamChunks } from './convert.ts' @@ -69,8 +70,8 @@ type Payload = { stop?: unknown } -function rawToolArguments(options: GenerateOptions): Map { - const raw = new Map() +function rawToolArguments(options: GenerateOptions): Map { + const raw = new Map() for (const message of options.messages) { if (message.role !== 'assistant') continue for (const block of message.content) { @@ -116,7 +117,7 @@ function patchPayload(payload: unknown, options: GenerateOptions, reasoning: PiA for (const call of message.tool_calls ?? []) { /* v8 ignore next -- malformed pi-ai payload guard: real tool calls always carry a string id */ if (typeof call.id !== 'string') continue - const raw = rawById.get(call.id) + const raw = rawById.get(CallId(call.id)) /* v8 ignore next -- pi-ai always emits a function object for assistant tool_calls; guard malformed payloads defensively */ if (raw !== undefined && call.function !== undefined) call.function.arguments = raw } diff --git a/packages/llm/llm-pi-ai/src/convert.ts b/packages/llm/llm-pi-ai/src/convert.ts index 4610ff01c9..0ddc41386a 100644 --- a/packages/llm/llm-pi-ai/src/convert.ts +++ b/packages/llm/llm-pi-ai/src/convert.ts @@ -57,7 +57,7 @@ function parseArguments(raw: string): Record { * same id. */ export function toPiContext(options: GenerateOptions): PiContext { - const toolNames = new Map() + const toolNames = new Map() const messages: PiMessage[] = [] for (const message of options.messages) { diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index 317edc7ac2..0983c893d3 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -20,9 +20,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", "cordis": "^4.0.0-rc.6" } } diff --git a/packages/llm/llm/src/brand.ts b/packages/llm/llm/src/brand.ts index 38d69fe37b..3082cc0141 100644 --- a/packages/llm/llm/src/brand.ts +++ b/packages/llm/llm/src/brand.ts @@ -1,24 +1,15 @@ /** - * Branded (nominal) ID types. + * dsh-llm's owned branded id: `CallId` (tool-call correlation). * - * 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 strings at runtime. Construction goes through the - * per-type factory (a plain cast inside — zero runtime cost); comparison, - * logging, and serialization all behave as ordinary strings. - * - * Policy: core packages brand the IDs they own — `CallId` here (tool-call - * correlation), `SessionId` in dsh-session, `AgentId` in dsh-agent. Branding - * is for IDs that cross package boundaries and could plausibly be confused; - * not every string needs a brand. + * The `Branded` primitive itself lives in `@deepseek-ai/dsh-brand` (a + * zero-dependency type-only package) so every owner of a cross-boundary id can + * brand it without depending on dsh-llm; see that package's README for the + * nominal-typing policy. * * @module @deepseek-ai/dsh-llm/brand */ -declare const BRAND: unique symbol - -/** A string carrying a compile-time-only brand `B`. */ -export type Branded = string & { readonly [BRAND]: B } +import type { Branded } from '@deepseek-ai/dsh-brand' /** * Correlates a model-issued tool call with its result. Provider-issued for diff --git a/packages/llm/llm/tsconfig.json b/packages/llm/llm/tsconfig.json index 10dabc415e..0e8e8c1878 100644 --- a/packages/llm/llm/tsconfig.json +++ b/packages/llm/llm/tsconfig.json @@ -13,6 +13,9 @@ }, { "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" } ] } diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 86c4a9d08b..8fa02665eb 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -274,8 +274,8 @@ describe('SessionPersistenceJsonl: write path (session/event → flush)', () => await ctx.plugin(SessionStore) await ctx.plugin(SessionPersistenceJsonl, { root }) - const a = ctx.sessions.create('sa') - const b = ctx.sessions.create('sb') + const a = ctx.sessions.create(SessionId('sa')) + const b = ctx.sessions.create(SessionId('sb')) a.append('user/message', { content: [{ type: 'text', text: 'A' }], source: { kind: 'user' } }) b.append('user/message', { content: [{ type: 'text', text: 'B' }], source: { kind: 'user' } }) a.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -451,7 +451,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { it('a DIFFERENT live session object reusing a disposed id gets its own init (no stale cache)', async () => { // Session A materializes a log under id "reuse". const sessFiberA = await ctx.plugin(Object.assign((inner: Context) => { - const a = inner.sessions.create('reuse', { meta: { cwd: '/a' } }) + const a = inner.sessions.create(SessionId('reuse'), { meta: { cwd: '/a' } }) for (const e of oneTurnLog()) a.append(e.type, e.data) }, { inject: ['sessions'] })) // Drain A, then dispose ITS fiber (the live session A is gone) while the @@ -466,7 +466,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { const backend = ctx.sessionPersistence as unknown as { inits: Map> } let b!: Session await ctx.plugin(Object.assign((inner: Context) => { - b = inner.sessions.create('reuse', { meta: { cwd: '/a' } }) + b = inner.sessions.create(SessionId('reuse'), { meta: { cwd: '/a' } }) }, { inject: ['sessions'] })) await expect(backend.inits.get(b)).rejects.toThrow(/already bound to a different live session|already has a persisted log on disk/) }) @@ -493,7 +493,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { const backend = ctx2.sessionPersistence as unknown as { inits: Map> } let b!: Session await ctx2.plugin(Object.assign((inner: Context) => { - b = inner.sessions.create('x') // no cwd + b = inner.sessions.create(SessionId('x')) // no cwd }, { inject: ['sessions'] })) await expect(backend.inits.get(b)).rejects.toThrow(/already has a persisted log on disk/) @@ -521,7 +521,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { if (userMsg?.type === 'user/message') userMsg.data.content = [{ type: 'text', text: 'DIFFERENT' }] let bad!: Session await ctx.plugin(Object.assign((inner: Context) => { - bad = inner.sessions.create('divergent', { seed: tampered, meta: { cwd: '/a' } }) + bad = inner.sessions.create(SessionId('divergent'), { seed: tampered, meta: { cwd: '/a' } }) }, { inject: ['sessions'] })) await expect(backend.inits.get(bad)).rejects.toThrow(/do not match this live session|already has a persisted log/) }) @@ -529,7 +529,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { it('a second live session reusing a bound id is rejected', async () => { // A live session materializes and owns the id. const firstFiber = await ctx.plugin(Object.assign((inner: Context) => { - const a = inner.sessions.create('bound', { meta: { cwd: '/a' } }) + const a = inner.sessions.create(SessionId('bound'), { meta: { cwd: '/a' } }) a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) a.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) }, { inject: ['sessions'] })) @@ -539,7 +539,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { const backend = ctx.sessionPersistence as unknown as { inits: Map> } let second!: Session await ctx.plugin(Object.assign((inner: Context) => { - second = inner.sessions.create('bound', { meta: { cwd: '/a' } }) + second = inner.sessions.create(SessionId('bound'), { meta: { cwd: '/a' } }) }, { inject: ['sessions'] })) await expect(backend.inits.get(second)) .rejects.toThrow(/already bound to a different live session|already has a persisted log|do not match/) @@ -580,7 +580,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { const backend = ctx2.sessionPersistence as unknown as { inits: Map> } let s!: Session await ctx2.plugin(Object.assign((inner: Context) => { - s = inner.sessions.create('exists-fault', { meta: { cwd } }) + s = inner.sessions.create(SessionId('exists-fault'), { meta: { cwd } }) }, { inject: ['sessions'] })) await expect(backend.inits.get(s)).rejects.toThrow(/ENOTDIR/) await ctx2.fiber.dispose() @@ -645,7 +645,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { const ctx2 = new Context() await ctx2.plugin(SessionStore) await ctx2.plugin(SessionPersistenceJsonl, { root }) - const session = ctx2.sessions.create('flush-fail') + const session = ctx2.sessions.create(SessionId('flush-fail')) // A full turn lands in the write-behind buffer. session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -689,7 +689,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { }) it('Session.append rejects a non-serializable event at the source (never enters the log)', () => { - const session = ctx.sessions.create('reject-bad') + const session = ctx.sessions.create(SessionId('reject-bad')) // Serializability is enforced at the source: Session.append throws on a // BigInt-bearing event BEFORE it enters session.events, so the durable log // can never diverge from the live log. The throw surfaces at the caller's diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index 262a085ce2..2bc9c59643 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite' import { openDatabase, scanRows, type EventRow } from '../src/schema.ts' @@ -352,7 +352,7 @@ describe('SessionPersistenceSqlite: edge cases', () => { const path = await freshDbPath() // Instance 1 materializes a session and disposes. const b1 = await backend(path) - const s1 = b1.ctx.sessions.create('hmr-collide') + const s1 = b1.ctx.sessions.create(SessionId('hmr-collide')) for (const e of oneTurnLog()) s1.append(e.type, e.data) await b1.ctx.parallel('session/flush', s1) await b1.dispose() @@ -363,7 +363,7 @@ describe('SessionPersistenceSqlite: edge cases', () => { await ctx.plugin(SessionStore) let session!: Session await ctx.plugin(Object.assign((inner: Context) => { - session = inner.sessions.create('hmr-collide') + session = inner.sessions.create(SessionId('hmr-collide')) }, { inject: ['sessions'] })) session.append('turn/start', { turn: 9, trigger: { kind: 'message', source: { kind: 'user' } } }) await ctx.plugin(SessionPersistenceSqlite, { path }) diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index 58f1246763..d0f8717ff1 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -157,14 +157,14 @@ async function settledErrors(promises: Iterable>): Promise { /** Backend bookkeeping keyed by session id (NOT the live Session object). */ - private states = new Map() + private states = new Map() /** Write-behind buffers keyed by the live Session (write path). */ private buffers = new Map() /** * Per-session serialization: every operation chains onto the prior one for the * same id, so writes for one session never interleave. Keyed by session id. */ - private chains = new Map>() + private chains = new Map>() /** * Per-session init promise (onCreated). Keyed by the LIVE Session OBJECT, not * its id: a disposed fiber's session can be replaced by a different live diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index 1d9a1339d1..d7c7ad44b4 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -85,7 +85,7 @@ async function liveSessionInFiber( ): Promise { let session!: Session await ctx.plugin(Object.assign((inner: Context) => { - session = inner.sessions.create(id, cwd !== undefined ? { meta: { cwd } } : undefined) + session = inner.sessions.create(SessionId(id), cwd !== undefined ? { meta: { cwd } } : undefined) }, { inject: ['sessions'] })) return session } @@ -110,7 +110,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) try { - const session = ctx.sessions.create('live', { meta: { cwd: WORK } }) + const session = ctx.sessions.create(SessionId('live'), { meta: { cwd: WORK } }) send(session, oneTurnLog()) await ctx.parallel('session/flush', session) @@ -127,7 +127,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) try { - const session = ctx.sessions.create('mutate', { meta: { cwd: WORK } }) + const session = ctx.sessions.create(SessionId('mutate'), { meta: { cwd: WORK } }) const ev = session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }) // Mutate the live event object AFTER it was buffered by session/event. ;(ev.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED' @@ -177,7 +177,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< try { const seed = oneTurnLog() // A fork: a brand-new id whose seed came from elsewhere. - const forked = ctx.sessions.create('forked', { seed, meta: { cwd: WORK } }) + const forked = ctx.sessions.create(SessionId('forked'), { seed, meta: { cwd: WORK } }) await inits(ctx.sessionPersistence).get(forked) // onCreated persisted the seed const loaded = await ctx.sessionPersistence.load(SessionId('forked')) expect(loaded.events).toEqual(seed) @@ -196,7 +196,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const first = await freshCtx(fix) try { // First lifecycle: persist a session through the store. - const s1 = first.ctx.sessions.create('resumed', { meta: { cwd: WORK } }) + const s1 = first.ctx.sessions.create(SessionId('resumed'), { meta: { cwd: WORK } }) send(s1, oneTurnLog()) await first.ctx.parallel('session/flush', s1) } finally { @@ -209,7 +209,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const second = await freshCtx(fix) try { const loaded = await second.ctx.sessionPersistence.load(SessionId('resumed')) - const s2 = second.ctx.sessions.create('resumed', { seed: loaded.events, meta: { cwd: WORK } }) + const s2 = second.ctx.sessions.create(SessionId('resumed'), { seed: loaded.events, meta: { cwd: WORK } }) await inits(second.ctx.sessionPersistence).get(s2) // let onCreated adopt s2.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) s2.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) @@ -231,7 +231,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const ctx = new Context() await ctx.plugin(SessionStore) // A session exists BEFORE the persistence plugin is applied. - const session = ctx.sessions.create('pre-existing', { meta: { cwd: WORK } }) + const session = ctx.sessions.create(SessionId('pre-existing'), { meta: { cwd: WORK } }) session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -371,7 +371,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const fix = await makeFixture() const first = await freshCtx(fix) try { - const s1 = first.ctx.sessions.create('collide', { meta: { cwd: WORK } }) + const s1 = first.ctx.sessions.create(SessionId('collide'), { meta: { cwd: WORK } }) send(s1, oneTurnLog()) await first.ctx.parallel('session/flush', s1) } finally { @@ -383,7 +383,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // exists. The rejection surfaces via the init promise (flush awaits it). const second = await freshCtx(fix) try { - const s2 = second.ctx.sessions.create('collide', { meta: { cwd: WORK } }) + const s2 = second.ctx.sessions.create(SessionId('collide'), { meta: { cwd: WORK } }) s2.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) await expect(inits(second.ctx.sessionPersistence).get(s2)) .rejects.toThrow(/already has a persisted log|id collision/) @@ -401,14 +401,14 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // never materialized. A new live session reusing the id must reclaim it. let firstSession!: Session const firstFiber = await ctx.plugin(Object.assign((inner: Context) => { - firstSession = inner.sessions.create('abandoned', { meta: { cwd: WORK } }) + firstSession = inner.sessions.create(SessionId('abandoned'), { meta: { cwd: WORK } }) }, { inject: ['sessions'] })) await inits(ctx.sessionPersistence).get(firstSession) // register the lazy state await firstFiber.dispose() // disposed before any append → never materialized let reuse!: Session await ctx.plugin(Object.assign((inner: Context) => { - reuse = inner.sessions.create('abandoned', { meta: { cwd: WORK } }) + reuse = inner.sessions.create(SessionId('abandoned'), { meta: { cwd: WORK } }) }, { inject: ['sessions'] })) await expect(inits(ctx.sessionPersistence).get(reuse)).resolves.toBeUndefined() reuse.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -428,7 +428,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< try { let first!: Session const firstFiber = await ctx.plugin(Object.assign((inner: Context) => { - first = inner.sessions.create('buffered', { meta: { cwd: WORK } }) + first = inner.sessions.create(SessionId('buffered'), { meta: { cwd: WORK } }) }, { inject: ['sessions'] })) await inits(ctx.sessionPersistence).get(first) // Append a turn but do NOT flush — events sit in the write-behind buffer. @@ -438,7 +438,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< let reuse!: Session await ctx.plugin(Object.assign((inner: Context) => { - reuse = inner.sessions.create('buffered', { meta: { cwd: WORK } }) + reuse = inner.sessions.create(SessionId('buffered'), { meta: { cwd: WORK } }) }, { inject: ['sessions'] })) await expect(inits(ctx.sessionPersistence).get(reuse)).rejects.toThrow(/already bound to a different live session/) } finally { @@ -451,7 +451,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) try { - const session = ctx.sessions.create('idem', { meta: { cwd: WORK } }) + const session = ctx.sessions.create(SessionId('idem'), { meta: { cwd: WORK } }) session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.parallel('session/flush', session) @@ -476,7 +476,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await ctx.sessionPersistence.create(meta('lazy-claim', WORK)) // A live session with that id arrives and claims it (cursor 0 matches // trivially), persisting its seed. - const live = ctx.sessions.create('lazy-claim', { seed: oneTurnLog(), meta: { cwd: WORK } }) + const live = ctx.sessions.create(SessionId('lazy-claim'), { seed: oneTurnLog(), meta: { cwd: WORK } }) await expect(inits(ctx.sessionPersistence).get(live)).resolves.toBeUndefined() const loaded = await ctx.sessionPersistence.load(SessionId('lazy-claim')) expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5]) @@ -500,7 +500,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // seq 0..cursor-1 events would otherwise be filtered as already-persisted. let fresh!: Session await ctx.plugin(Object.assign((inner: Context) => { - fresh = inner.sessions.create('preview', { meta: { cwd: WORK } }) + fresh = inner.sessions.create(SessionId('preview'), { meta: { cwd: WORK } }) }, { inject: ['sessions'] })) await expect(inits(ctx.sessionPersistence).get(fresh)) .rejects.toThrow(/do not match this live session|already has a persisted log|id collision/) @@ -521,7 +521,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // A live session SEEDED with the loaded log PLUS a new turn claims the // ownerless state and persists only the suffix. - const cont = ctx.sessions.create('claim', { seed: [ + const cont = ctx.sessions.create(SessionId('claim'), { seed: [ ...events, { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, @@ -545,7 +545,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // A live session reusing the id but at cwd WORK must NOT claim it — the // cwd scope is the fence (without it, WORK events would append under the // OTHER header). Rejected as a collision. - const live = ctx.sessions.create('wrong-cwd-claim', { seed: oneTurnLog(), meta: { cwd: WORK } }) + const live = ctx.sessions.create(SessionId('wrong-cwd-claim'), { seed: oneTurnLog(), meta: { cwd: WORK } }) await expect(inits(ctx.sessionPersistence).get(live)).rejects.toThrow(/different cwd|id collision/) } finally { await fiber.dispose() @@ -563,7 +563,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const { events } = await ctx.sessionPersistence.load(SessionId('wrong-cwd-load')) // A live session whose SEED matches the loaded prefix but whose cwd is // WORK must still be rejected — the cwd guard runs before the seed check. - const live = ctx.sessions.create('wrong-cwd-load', { seed: events, meta: { cwd: WORK } }) + const live = ctx.sessions.create(SessionId('wrong-cwd-load'), { seed: events, meta: { cwd: WORK } }) await expect(inits(ctx.sessionPersistence).get(live)).rejects.toThrow(/different cwd|id collision/) } finally { await fiber.dispose() @@ -579,7 +579,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await ctx.sessionPersistence.create(meta('no-cwd-state')) // A live session reusing the id but WITH cwd WORK is a cwd mismatch // (undefined vs WORK) and must be rejected. - const live = ctx.sessions.create('no-cwd-state', { seed: oneTurnLog(), meta: { cwd: WORK } }) + const live = ctx.sessions.create(SessionId('no-cwd-state'), { seed: oneTurnLog(), meta: { cwd: WORK } }) await expect(inits(ctx.sessionPersistence).get(live)).rejects.toThrow(/different cwd|id collision/) } finally { await fiber.dispose() @@ -703,7 +703,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // Append directly to a live session and flush IMMEDIATELY, before the // async onCreated init has necessarily set state (exercises the // state-undefined cursor path). - const session = ctx.sessions.create('flush-nostate', { meta: { cwd: WORK } }) + const session = ctx.sessions.create(SessionId('flush-nostate'), { meta: { cwd: WORK } }) session.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.parallel('session/flush', session) diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index b201e1ae43..9b4b4ddfa0 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -21,6 +21,7 @@ import type { Context } from 'cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { CallId } from '@deepseek-ai/dsh-llm' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' @@ -65,7 +66,7 @@ interface SessionTrace { * Tool-call ids issued in the OPEN step awaiting a result. Cleared at * `step/end` — a result must arrive in the same step as its call. */ - pendingCalls: Set + pendingCalls: Set } /** diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index 1086a21bfb..f1bf2a2e74 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import * as Invariants from '@deepseek-ai/dsh-invariants' import { InvariantError } from '@deepseek-ai/dsh-invariants' @@ -175,8 +175,8 @@ describe('session-log invariants', () => { it('tracks turns per session independently', async () => { const { ctx } = await setup({ freeze: false }) - const a = ctx.sessions.create('a') - const b = ctx.sessions.create('b') + const a = ctx.sessions.create(SessionId('a')) + const b = ctx.sessions.create(SessionId('b')) a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) // b is a fresh session — its own turn/start must not see a's open turn. expect(() => b.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })).not.toThrow() diff --git a/packages/support/ui-stdio/src/index.ts b/packages/support/ui-stdio/src/index.ts index d52370d1ed..4d93ed3f86 100644 --- a/packages/support/ui-stdio/src/index.ts +++ b/packages/support/ui-stdio/src/index.ts @@ -22,7 +22,7 @@ import { createInterface } from 'node:readline' import type { Readable, Writable } from 'node:stream' import type { Context } from 'cordis' import z from 'schemastery' -import type {} from '@deepseek-ai/dsh-agent' +import { AgentId } from '@deepseek-ai/dsh-agent' export const name = 'ui-stdio' export const inject = ['agents'] @@ -69,7 +69,7 @@ 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 = config.agent ?? 'main' + const agentId = AgentId(config.agent ?? 'main') const { input, output, exit } = runtime let inReasoning = false diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 089379e83a..2964726b94 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -60,7 +60,10 @@ import { type StopReason, } from '@agentclientprotocol/sdk' import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { CallId } from '@deepseek-ai/dsh-llm' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' +import { AgentId } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' import type { ToolCallKind, ToolCallPresentation, ToolRegistry, ToolResultPresentation, ToolTerminal } from '@deepseek-ai/dsh-tools' // Side-effect type import: declaration-merges `ctx.sessionPersistence` onto @@ -138,7 +141,7 @@ export const Config: Schema = Schema.object({ * map keyed by id (RFC 011 multi-session). */ interface SessionRecord { - sessionId: string + sessionId: SessionId agent: Agent /** * The owned-agent disposer (from the {@link AgentHandle} the factory returned). @@ -229,12 +232,12 @@ export function apply(ctx: Context, config: AcpConfig): void { // reverse map so `agent/*` events (which carry only the Agent) demux in O(1). // The two stay in lockstep: a record is added to `sessions` and the agent to // `bySession` together, and removed together. - const sessions = new Map() - const bySession = new WeakMap() + const sessions = new Map() + const bySession = new WeakMap() // Session ids whose `session/load` is mid-`resume()` (the slot is reserved // before the async resume so a pipelined load/new for the SAME id can't create // two agents). Distinct ids load concurrently; a given id loads once at a time. - const loadingIds = new Set() + const loadingIds = new Set() // Set once the bridge has torn down (disposal or client disconnect). An async // `session/load` mid-`resume()` when teardown ran must observe this after its // await and NOT install a record (which would resurrect a live agent/listeners @@ -265,7 +268,7 @@ export function apply(ctx: Context, config: AcpConfig): void { } /** Resolve the live record for a sessionId, or throw an ACP error. */ - const requireSession = (sessionId: string): SessionRecord => { + const requireSession = (sessionId: SessionId): SessionRecord => { const rec = sessions.get(sessionId) if (rec === undefined) { throw invalidParams(`unknown session: ${sessionId}`) @@ -446,9 +449,9 @@ export function apply(ctx: Context, config: AcpConfig): void { assertOpen() validateWorkspaceParams(params) validateMcpServers(params) - const sessionId = randomUUID() + const sessionId = SessionId(randomUUID()) const handle = agents.create({ - agentId: sessionId, + agentId: AgentId(sessionId), sessionId, meta: { cwd: params.cwd }, agentOptions: agentOptions(config), @@ -467,8 +470,11 @@ export function apply(ctx: Context, config: AcpConfig): void { async loadSession(params: LoadSessionRequest): Promise { assertOpen() - if (sessions.has(params.sessionId) || loadingIds.has(params.sessionId)) { - throw invalidParams(`session ${params.sessionId} is already loaded`) + // The wire `params.sessionId` is a raw protocol string; brand it once at + // this entry so the session collections and the resume factory see a SessionId. + const sessionId = SessionId(params.sessionId) + if (sessions.has(sessionId) || loadingIds.has(sessionId)) { + throw invalidParams(`session ${sessionId} is already loaded`) } validateWorkspaceParams(params) validateMcpServers(params) @@ -477,7 +483,7 @@ export function apply(ctx: Context, config: AcpConfig): void { // resume() is pending, then both install a record and leak a second // agent. (Distinct ids load concurrently — the set is keyed by id.) The // slot is released in `finally` so a rejected load never wedges the id. - loadingIds.add(params.sessionId) + loadingIds.add(sessionId) try { // Validate the PERSISTED cwd BEFORE resuming — `list()` is a // metadata-only read (no full-log parse), so this rejects a session we @@ -491,21 +497,21 @@ export function apply(ctx: Context, config: AcpConfig): void { // always has a cwd (session/new requires it); reject the rest loudly. // (An id unknown to `list()` falls through to resume, which rejects with // the backend's not-found error.) - const meta = (await sessionPersistence.list()).find(m => m.id === params.sessionId) + const meta = (await sessionPersistence.list()).find(m => m.id === sessionId) if (meta !== undefined) { const persistedCwd = meta.cwd if (persistedCwd === undefined || !isAbsolute(persistedCwd)) { throw invalidParams( - `session ${params.sessionId} has no absolute persisted cwd; cannot determine its workspace (it predates per-session cwd, or was created without one)`, + `session ${sessionId} has no absolute persisted cwd; cannot determine its workspace (it predates per-session cwd, or was created without one)`, ) } if (!sameWorkspaceCwd(persistedCwd, params.cwd)) { - throw invalidParams(`session ${params.sessionId} cwd mismatch: persisted ${persistedCwd}, requested ${params.cwd}`) + throw invalidParams(`session ${sessionId} cwd mismatch: persisted ${persistedCwd}, requested ${params.cwd}`) } } const handle = await agents.resume({ - agentId: params.sessionId, - resumeSessionId: params.sessionId, + agentId: AgentId(sessionId), + resumeSessionId: sessionId, agentOptions: agentOptions(config), }) // The bridge may have torn down (disposal / client disconnect) while @@ -523,20 +529,20 @@ export function apply(ctx: Context, config: AcpConfig): void { throw invalidParams('connection closed during session/load') } const agent = handle.agent - bySession.set(agent, params.sessionId) + 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: params.sessionId, + sessionId, agent, dispose: () => handle.dispose(), presenter: makePresenter(), terminalEnabled, inflight: undefined, } - sessions.set(params.sessionId, record) + sessions.set(sessionId, record) // Replay the persisted event log to the client as session/update. Use // the raw event log (NOT deriveMessages, which drops assistant/chunk // and trace events): RFC 010's load contract reconstructs the streamed @@ -556,17 +562,17 @@ export function apply(ctx: Context, config: AcpConfig): void { cwd: agent.session.header.cwd, } for (const event of agent.session.events) { - streamSessionEventUpdate(params.sessionId, event, notify, replayPresenter, replayTerminal) + streamSessionEventUpdate(sessionId, event, notify, replayPresenter, replayTerminal) } return {} } finally { - loadingIds.delete(params.sessionId) + loadingIds.delete(sessionId) } }, async prompt(params: PromptRequest): Promise { assertOpen() - const rec = requireSession(params.sessionId) + const rec = requireSession(SessionId(params.sessionId)) if (rec.inflight !== undefined) { throw invalidParams('a prompt is already in flight for this session') } @@ -595,7 +601,7 @@ export function apply(ctx: Context, config: AcpConfig): void { }, cancel(params: CancelNotification): Promise { - const rec = sessions.get(params.sessionId) + const rec = sessions.get(SessionId(params.sessionId)) if (rec === undefined) return Promise.resolve() // session/cancel maps to the queue-aware agent.cancel(reason): it aborts // a RUNNING step, clears the queued + steering FIFOs, and drops a @@ -773,7 +779,7 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void { * no client update. */ export function streamSessionEventUpdate( - sessionId: string, + sessionId: SessionId, event: SessionEvent, notify: (notification: SessionNotification) => void, presenter: Pick = nullToolPresenter, @@ -938,7 +944,7 @@ interface ResolvedResultPresentation { * stale entry's only cost is one map slot until the session ends. */ export class ToolPresenter { - private readonly pending = new Map() + private readonly pending = new Map() /** * @param tools the registry to resolve tool definitions by name. @@ -954,7 +960,7 @@ export class ToolPresenter { ) {} /** Pending-state presentation for a `tool/call`; remembers `(name, args)` for the matching result. */ - call(callId: string, name: string, argsJson: string): ResolvedCallPresentation { + call(callId: CallId, name: string, argsJson: string): ResolvedCallPresentation { const args = parseToolArguments(argsJson) let present: ToolCallPresentation | undefined try { @@ -986,7 +992,7 @@ export class ToolPresenter { } /** Completed-state presentation for a `tool/result`; consumes the remembered `(name, args)`. */ - result(callId: string, content: ContentBlock[], isError: boolean): ResolvedResultPresentation { + result(callId: CallId, content: ContentBlock[], isError: boolean): ResolvedResultPresentation { const call = this.pending.get(callId) this.pending.delete(callId) // No remembered call (unknown/late callId) → nothing to present from; raw content. diff --git a/packages/ui/acp/tests/bridge.spec.ts b/packages/ui/acp/tests/bridge.spec.ts index dd10a88bcb..e9ebca8d62 100644 --- a/packages/ui/acp/tests/bridge.spec.ts +++ b/packages/ui/acp/tests/bridge.spec.ts @@ -3,6 +3,7 @@ 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 } from './harness.ts' /** @@ -61,8 +62,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(a.sessionId)).toBeDefined() - expect(harness.ctx.agents.get(b.sessionId)).toBeDefined() + expect(harness.ctx.agents.get(AgentId(a.sessionId))).toBeDefined() + expect(harness.ctx.agents.get(AgentId(b.sessionId))).toBeDefined() }) it('rejects a non-absolute cwd but accepts any absolute cwd (per-session workspace)', async () => { @@ -77,7 +78,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(res.sessionId)!.session.header.cwd).toBe('/tmp') + expect(harness.ctx.agents.get(AgentId(res.sessionId))!.session.header.cwd).toBe('/tmp') }) it('rejects non-empty additionalDirectories', async () => { @@ -117,7 +118,7 @@ describe('acp bridge', () => { ], }) expect(result.stopReason).toBe('end_turn') - const user = harness.ctx.agents.get(sessionId)!.session.events.find(event => event.type === 'user/message') + const user = harness.ctx.agents.get(AgentId(sessionId))!.session.events.find(event => event.type === 'user/message') expect(JSON.stringify(user)).toContain('resource_link') }) diff --git a/packages/ui/acp/tests/dispose.spec.ts b/packages/ui/acp/tests/dispose.spec.ts index dd27cc7e34..ac092d9d16 100644 --- a/packages/ui/acp/tests/dispose.spec.ts +++ b/packages/ui/acp/tests/dispose.spec.ts @@ -4,6 +4,7 @@ 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', () => { @@ -16,7 +17,7 @@ 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(sessionId)! + const agent = harness.ctx.agents.get(AgentId(sessionId))! // Start a prompt that hangs in the model stream. const promptDone = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) @@ -61,10 +62,10 @@ 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: [] }) - expect(harness.ctx.agents.get(sessionId)).toBeDefined() + expect(harness.ctx.agents.get(AgentId(sessionId))).toBeDefined() await harness.acpFiber.dispose() // tear down ONLY the bridge - expect(harness.ctx.agents.get(sessionId)).toBeUndefined() + expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined() await harness.dispose() }) @@ -91,7 +92,7 @@ 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(sessionId)! + const agent = harness.ctx.agents.get(AgentId(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(() => {}) @@ -114,8 +115,8 @@ describe('acp bridge — disposal & HMR safety', () => { // 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(sessionId)).toBeUndefined() - expect(harness.ctx.sessions.get(sessionId)).toBeUndefined() + expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined() + expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined() await harness.dispose() }) @@ -127,7 +128,7 @@ 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(sessionId)! + const agent = harness.ctx.agents.get(AgentId(sessionId))! void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {}) await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') @@ -144,7 +145,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(sessionId)!.session + const session = harness.ctx.agents.get(AgentId(sessionId))!.session await harness.ctx.fiber.dispose() const before = harness.updates.length @@ -168,12 +169,12 @@ describe('acp bridge — disposal & HMR safety', () => { 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(sessionId)!.session.events.length + const liveEvents = harness.ctx.agents.get(AgentId(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(sessionId)).toBeUndefined() + expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined() // Re-load the session from disk: every live event (incl. the closing // turn/end) was flushed before the session was detached. @@ -200,7 +201,7 @@ 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(sessionId)! + const agent = harness.ctx.agents.get(AgentId(sessionId))! void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {}) await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') @@ -210,7 +211,7 @@ describe('acp bridge — disposal & HMR safety', () => { // 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(sessionId)).toBeUndefined() + expect(harness.ctx.agents.get(AgentId(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. @@ -229,22 +230,22 @@ describe('acp bridge — disposal & HMR safety', () => { // queryable, with its session still in the store. const harness = await makeBridgeHarness({ storageDir, script: [] }) const handleA = harness.ctx.agents.create({ - agentId: 'sib-a', sessionId: 'sib-a', agentOptions: { model: 'mock' }, + agentId: AgentId('sib-a'), sessionId: SessionId('sib-a'), agentOptions: { model: 'mock' }, }) const handleB = harness.ctx.agents.create({ - agentId: 'sib-b', sessionId: 'sib-b', agentOptions: { model: 'mock' }, + agentId: AgentId('sib-b'), sessionId: SessionId('sib-b'), agentOptions: { model: 'mock' }, }) - expect(harness.ctx.agents.get('sib-a')).toBe(handleA.agent) - expect(harness.ctx.agents.get('sib-b')).toBe(handleB.agent) + expect(harness.ctx.agents.get(AgentId('sib-a'))).toBe(handleA.agent) + expect(harness.ctx.agents.get(AgentId('sib-b'))).toBe(handleB.agent) await handleA.dispose() // A is gone — unregistered AND its session removed from the store. - expect(harness.ctx.agents.get('sib-a')).toBeUndefined() - expect(harness.ctx.sessions.get('sib-a')).toBeUndefined() + expect(harness.ctx.agents.get(AgentId('sib-a'))).toBeUndefined() + expect(harness.ctx.sessions.get(SessionId('sib-a'))).toBeUndefined() expect(handleA.agent.status).toBe('disposed') // B is wholly unaffected. - expect(harness.ctx.agents.get('sib-b')).toBe(handleB.agent) - expect(harness.ctx.sessions.get('sib-b')).toBeDefined() + expect(harness.ctx.agents.get(AgentId('sib-b'))).toBe(handleB.agent) + expect(harness.ctx.sessions.get(SessionId('sib-b'))).toBeDefined() expect(handleB.agent.status).not.toBe('disposed') await harness.dispose() }) @@ -261,16 +262,16 @@ describe('acp bridge — disposal & HMR safety', () => { const harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] }) harness.ctx.on('agent/disposed', () => { throw new Error('boom disposed listener') }) const handle = harness.ctx.agents.create({ - agentId: 'guard-a', sessionId: 'guard-a', agentOptions: { model: 'mock' }, + agentId: AgentId('guard-a'), sessionId: SessionId('guard-a'), agentOptions: { model: 'mock' }, }) handle.agent.send([{ type: 'text', text: 'go' }]) await handle.agent.whenIdle() - expect(harness.ctx.sessions.get('guard-a')).toBeDefined() + 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('guard-a')).toBeUndefined() - expect(harness.ctx.sessions.get('guard-a')).toBeUndefined() // detach still ran + expect(harness.ctx.agents.get(AgentId('guard-a'))).toBeUndefined() + expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeUndefined() // detach still ran await harness.dispose() }) @@ -282,7 +283,7 @@ describe('acp bridge — disposal & HMR safety', () => { // observe the same quiescence boundary. const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) const handle = harness.ctx.agents.create({ - agentId: 'conc-a', sessionId: 'conc-a', agentOptions: { model: 'mock' }, + agentId: AgentId('conc-a'), sessionId: SessionId('conc-a'), agentOptions: { model: 'mock' }, }) // 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 @@ -312,8 +313,8 @@ describe('acp bridge — disposal & HMR safety', () => { // Release the flush; both resolve together and the session is gone. releaseFlush() await Promise.all([first, second]) - expect(harness.ctx.agents.get('conc-a')).toBeUndefined() - expect(harness.ctx.sessions.get('conc-a')).toBeUndefined() + expect(harness.ctx.agents.get(AgentId('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 b9e2377908..69c935139d 100644 --- a/packages/ui/acp/tests/edges.spec.ts +++ b/packages/ui/acp/tests/edges.spec.ts @@ -3,6 +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 { SessionId } from '@deepseek-ai/dsh-session' import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' describe('acp bridge — demux & config edges', () => { @@ -25,7 +27,7 @@ describe('acp bridge — demux & config edges', () => { await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) const before = harness.updates.length - const { agent: foreign } = harness.ctx.agents.create({ agentId: 'foreign', sessionId: 'foreign-session', agentOptions: { model: 'mock' } }) + const { agent: foreign } = harness.ctx.agents.create({ agentId: AgentId('foreign'), sessionId: SessionId('foreign-session'), agentOptions: { 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 b1e4acfda5..d254ee8885 100644 --- a/packages/ui/acp/tests/load.spec.ts +++ b/packages/ui/acp/tests/load.spec.ts @@ -4,6 +4,7 @@ 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, toolCallResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts' /** Concatenate the text of all agent_message_chunk updates. */ @@ -155,7 +156,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(sessionId)).toBeUndefined() + expect(loader.ctx.agents.get(AgentId(sessionId))).toBeUndefined() }) it('rejects load when the requested cwd does not match the persisted session cwd', async () => { @@ -176,11 +177,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('elsewhere')).toBeUndefined() + expect(loader.ctx.agents.get(AgentId('elsewhere'))).toBeUndefined() const res = await loader.client.loadSession({ sessionId: 'elsewhere', cwd: `${otherCwd}/.`, mcpServers: [] }) expect(res).toBeDefined() - expect(loader.ctx.agents.get('elsewhere')!.session.header.cwd).toBe(otherCwd) + expect(loader.ctx.agents.get(AgentId('elsewhere'))!.session.header.cwd).toBe(otherCwd) }) it('rejects load for a non-absolute cwd (still required to be absolute)', async () => { @@ -215,7 +216,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('legacy')).toBeUndefined() + expect(loader.ctx.agents.get(AgentId('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 1c20d2ba39..ca11934046 100644 --- a/packages/ui/acp/tests/multi-session.spec.ts +++ b/packages/ui/acp/tests/multi-session.spec.ts @@ -3,6 +3,7 @@ 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' /** Text of the agent_message_chunk updates scoped to one session id. */ @@ -101,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(a)! - const agentB = harness.ctx.agents.get(b)! + const agentA = harness.ctx.agents.get(AgentId(a))! + const agentB = harness.ctx.agents.get(AgentId(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/properties.spec.ts b/packages/ui/acp/tests/properties.spec.ts index 5364c02d7b..3dcb4c760f 100644 --- a/packages/ui/acp/tests/properties.spec.ts +++ b/packages/ui/acp/tests/properties.spec.ts @@ -17,7 +17,7 @@ import { describe, expect, it } from 'vitest' import fc from 'fast-check' 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 type { SessionNotification } from '@agentclientprotocol/sdk' import { streamSessionEventUpdate } from '../src/index.ts' @@ -85,7 +85,7 @@ function actionsToEvents(actions: Action[]): SessionEvent[] { function runStream(events: SessionEvent[]): SessionNotification['update'][] { const out: SessionNotification['update'][] = [] - for (const event of events) streamSessionEventUpdate('s1', event, n => out.push(n.update)) + for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update)) return out } diff --git a/packages/ui/acp/tests/stream-update.spec.ts b/packages/ui/acp/tests/stream-update.spec.ts index cdba5a3bf3..737ad3804a 100644 --- a/packages/ui/acp/tests/stream-update.spec.ts +++ b/packages/ui/acp/tests/stream-update.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' 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 type { SessionNotification } from '@agentclientprotocol/sdk' import type { ToolDefinition, ToolRegistry } from '@deepseek-ai/dsh-tools' import { streamSessionEventUpdate, agentOptions, ToolPresenter } from '../src/index.ts' @@ -8,14 +8,14 @@ import { streamSessionEventUpdate, agentOptions, ToolPresenter } from '../src/in /** Collect the updates a single event produces (no presenter → generic fallback). */ function updatesFor(event: SessionEvent): SessionNotification['update'][] { const out: SessionNotification['update'][] = [] - streamSessionEventUpdate('s1', event, n => out.push(n.update)) + streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update)) return out } /** Collect the updates emitted by the live prompt stream (user echo suppressed). */ function liveUpdatesFor(event: SessionEvent): SessionNotification['update'][] { const out: SessionNotification['update'][] = [] - streamSessionEventUpdate('s1', event, n => out.push(n.update), undefined, undefined, { includeUserMessages: false }) + streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), undefined, undefined, { includeUserMessages: false }) return out } @@ -138,7 +138,7 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () => function updatesWith(presenter: ToolPresenter, ...events: SessionEvent[]): SessionNotification['update'][] { const out: SessionNotification['update'][] = [] - for (const event of events) streamSessionEventUpdate('s1', event, n => out.push(n.update), presenter) + for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter) return out } @@ -324,7 +324,7 @@ describe('terminal-card mapping (capability-gated)', () => { function termUpdates(tool: ToolDefinition, enabled: boolean, cwd: string | undefined, ...events: SessionEvent[]): SessionNotification['update'][] { const presenter = new ToolPresenter(registryOf(tool)) const out: SessionNotification['update'][] = [] - for (const event of events) streamSessionEventUpdate('s1', event, n => out.push(n.update), presenter, { enabled, cwd }) + for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter, { enabled, cwd }) return out } diff --git a/packages/ui/acp/tests/turns.spec.ts b/packages/ui/acp/tests/turns.spec.ts index 014dfecf91..7634602a35 100644 --- a/packages/ui/acp/tests/turns.spec.ts +++ b/packages/ui/acp/tests/turns.spec.ts @@ -3,6 +3,7 @@ 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, @@ -283,7 +284,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(sessionId)! + const agent = harness.ctx.agents.get(AgentId(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. @@ -339,7 +340,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(sessionId)! + const agent = harness.ctx.agents.get(AgentId(sessionId))! await agent.whenIdle() // At most ONE turn ran (the cancelled one) — the cancel cleared the queue, so // no second turn was batched or leaked. (A best-effort abort that left queued diff --git a/packages/util/brand/README.md b/packages/util/brand/README.md new file mode 100644 index 0000000000..8f7943def7 --- /dev/null +++ b/packages/util/brand/README.md @@ -0,0 +1,26 @@ +# dsh-brand + +The `Branded` nominal-typing primitive — a tiny, **type-only** package (no runtime code, no harness-package dependency) shared by every package that owns a cross-boundary id. + +## 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. + +```ts +import type { Branded } from '@deepseek-ai/dsh-brand' + +export type SessionId = Branded<'SessionId'> + +/** Brand a string as a SessionId (a plain cast — zero runtime cost). */ +export function SessionId(id: string): SessionId { + return id as SessionId +} +``` + +Construction goes through the per-id factory in the OWNING package (a plain cast inside — zero runtime cost). Comparison, logging, JSON serialization, and the wire format all behave exactly as for an ordinary string; the brand is erased at compile time. + +## Policy: brand ids that cross package boundaries + +A package brands the ids it OWNS — `CallId` in `dsh-llm` (tool-call correlation), `SessionId` in `dsh-session`, `AgentId` in `dsh-agent`, `BashTaskId`/`OwnerToken` in `dsh-bash`. 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 the (erased) type. Keeping the primitive dependency-free is the point: a capability package can brand its ids without depending on an unrelated package. `dsh-bash`, for example, brands `BashTaskId`/`OwnerToken` by depending on `dsh-brand` alone — it never pulls in `dsh-llm` (or `dsh-session`) just to reach `Branded`. diff --git a/packages/util/brand/package.json b/packages/util/brand/package.json new file mode 100644 index 0000000000..f0dcf7a8d7 --- /dev/null +++ b/packages/util/brand/package.json @@ -0,0 +1,28 @@ +{ + "name": "@deepseek-ai/dsh-brand", + "description": "Type-only Branded nominal-typing primitive for the DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/util/brand/src/index.ts b/packages/util/brand/src/index.ts new file mode 100644 index 0000000000..051ced94b7 --- /dev/null +++ b/packages/util/brand/src/index.ts @@ -0,0 +1,27 @@ +/** + * The `Branded` nominal-typing primitive — a type-only utility (no runtime + * code, no harness-package dependency) shared by every package that owns a + * 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 + * 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, + * `BashTaskId`/`OwnerToken` in dsh-bash. 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 + * the (erased) type — so the brand vocabulary stays dependency-free and a + * package can brand its ids without depending on an unrelated capability + * package (e.g. dsh-bash brands its ids without pulling in dsh-llm). + * + * @module @deepseek-ai/dsh-brand + */ + +declare const BRAND: unique symbol + +/** A string carrying a compile-time-only brand `B`. */ +export type Branded = string & { readonly [BRAND]: B } diff --git a/packages/util/brand/tsconfig.json b/packages/util/brand/tsconfig.json new file mode 100644 index 0000000000..f8fc535ab7 --- /dev/null +++ b/packages/util/brand/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e79f0d6ff..2d0f6ec270 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: packages/bash/bash: devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) @@ -117,6 +120,9 @@ importers: packages/core/agent: devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -163,6 +169,9 @@ importers: packages/core/session: devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -196,6 +205,9 @@ importers: packages/llm/llm: devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) @@ -365,6 +377,12 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/util/brand: + devDependencies: + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + vendor/cordis: dependencies: '@cordisjs/plugin-include': diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 975b09aa54..f46c4fca6b 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1,7 +1,7 @@ { "comment": "Maps each ` ```ts type-equiv ` block (by doc + declared symbol) to the source symbol it must match verbatim. verify-type-equiv.ts enforces a 1:1 correspondence: every type-equiv block has exactly one entry here, and every entry resolves to exactly one block. Add an entry when you add a type-equiv block; remove it when you remove the block.", "entries": [ - { "doc": "docs/core-data-structures/core.md", "symbol": "Branded", "source": "packages/llm/llm/src/brand.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "Branded", "source": "packages/util/brand/src/index.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "Message", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "MessageSourceMap", "source": "packages/llm/llm/src/types.ts" }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 04b65eb3a3..8e2070fe28 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -45,6 +45,7 @@ "./packages/bash/*/src", "./packages/session-persistence/*/src", "./packages/ui/*/src", + "./packages/util/*/src", "./packages/support/*/src" ] } diff --git a/tsconfig.build.json b/tsconfig.build.json index 6d353c1796..c9b377ab0d 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -10,6 +10,7 @@ { "path": "./vendor/timer" }, { "path": "./vendor/hmr" }, { "path": "./vendor/logger-console" }, + { "path": "./packages/util/brand" }, { "path": "./packages/llm/llm" }, { "path": "./packages/core/session" }, { "path": "./packages/session-persistence/session-persistence" }, diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json index a2b2358a09..54769bdbc0 100644 --- a/tsconfig.typecheck.json +++ b/tsconfig.typecheck.json @@ -22,6 +22,7 @@ "./packages/bash/*/src", "./packages/session-persistence/*/src", "./packages/ui/*/src", + "./packages/util/*/src", "./packages/support/*/src" ] }