feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand

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<B>` 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
This commit is contained in:
Tianyi Cui
2026-06-21 07:19:59 +08:00
parent 24168aee70
commit d6a2ab30c8
75 changed files with 644 additions and 445 deletions
+2 -1
View File
@@ -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 }]))
}
```
+25 -25
View File
@@ -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<AgentHandle>
```
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<AgentHandle>
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<BashRunResult>
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[]
```
+5 -3
View File
@@ -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). */
+4 -2
View File
@@ -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<B>` 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<B extends string> = 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
+9 -4
View File
@@ -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` |
+1 -1
View File
@@ -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
@@ -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<string, Session>()` and `create`/`prepare(id?: string)`/`get(id: string)` (`packages/core/session/src/index.ts`); `AgentRegistry.store = new Map<string, Agent>()` and `register`/`get(id: string)` (`packages/core/agent/src/index.ts`); `ToolPresenter.pending = new Map<string, …>()` 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<Agent, string>()`, `loadingIds = new Set<string>()`, `requireSession(sessionId: string)`, and the exported `streamSessionEventUpdate(sessionId: string, …)` (`packages/ui/acp/src/index.ts`); and the persistence coordinator's `Map<string, …>` 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.
@@ -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
@@ -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,
})
+2 -1
View File
@@ -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,
})
+5 -3
View File
@@ -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
+4
View File
@@ -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"]
+9 -9
View File
@@ -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<string, TrackedTask>()
private tasks = new Map<BashTaskId, TrackedTask>()
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
@@ -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 () => {
+3
View File
@@ -17,6 +17,9 @@
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../util/brand"
},
{
"path": "../../bash/bash"
}
+1 -1
View File
@@ -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.
+2
View File
@@ -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"
}
}
+6 -5
View File
@@ -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
+28 -3
View File
@@ -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). */
+8 -8
View File
@@ -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<string, BashTask>()
private owners = new Map<string, string | undefined>()
tasks = new Map<BashTaskId, BashTask>()
private owners = new Map<BashTaskId, OwnerToken | undefined>()
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
+3
View File
@@ -13,6 +13,9 @@
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../util/brand"
}
]
}
+7 -5
View File
@@ -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(
@@ -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
+22 -22
View File
@@ -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
+21 -13
View File
@@ -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-<uuid>`. 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<Config> = 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<Config>
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<AgentHandle> {
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`)
}
+23 -23
View File
@@ -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))
+13 -13
View File
@@ -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))
@@ -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-<uuid>),
@@ -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()
@@ -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',
+30 -30
View File
@@ -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(
@@ -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
+16 -16
View File
@@ -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()
})
@@ -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<boolean> => {
@@ -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)
+1 -1
View File
@@ -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)
+2
View File
@@ -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"
+7 -7
View File
@@ -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<string, Agent>()
private store = new Map<AgentId, Agent>()
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)
}
+2 -1
View File
@@ -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'>
+12 -12
View File
@@ -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/)
})
})
+3
View File
@@ -14,6 +14,9 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../../util/brand"
},
{
"path": "../../llm/llm"
},
+2 -2
View File
@@ -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
+2
View File
@@ -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"
}
+4 -4
View File
@@ -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<string, Session>()
private store = new Map<SessionId, Session>()
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)
}
+2 -1
View File
@@ -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'>
+21 -21
View File
@@ -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)
})
+3
View File
@@ -14,6 +14,9 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../../util/brand"
},
{
"path": "../../llm/llm"
}
+4 -3
View File
@@ -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<string, string> {
const raw = new Map<string, string>()
function rawToolArguments(options: GenerateOptions): Map<CallId, string> {
const raw = new Map<CallId, string>()
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
}
+1 -1
View File
@@ -57,7 +57,7 @@ function parseArguments(raw: string): Record<string, unknown> {
* same id.
*/
export function toPiContext(options: GenerateOptions): PiContext {
const toolNames = new Map<string, string>()
const toolNames = new Map<CallId, string>()
const messages: PiMessage[] = []
for (const message of options.messages) {
+2
View File
@@ -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"
}
}
+6 -15
View File
@@ -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<B>` 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<B extends string> = string & { readonly [BRAND]: B }
import type { Branded } from '@deepseek-ai/dsh-brand'
/**
* Correlates a model-issued tool call with its result. Provider-issued for
+3
View File
@@ -13,6 +13,9 @@
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../util/brand"
}
]
}
@@ -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<Session, Promise<void>> }
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<Session, Promise<void>> }
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<Session, Promise<void>> }
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<Session, Promise<void>> }
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
@@ -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 })
@@ -157,14 +157,14 @@ async function settledErrors(promises: Iterable<Promise<unknown>>): Promise<unkn
*/
export class PersistenceCoordinator<TornMarker = unknown> {
/** Backend bookkeeping keyed by session id (NOT the live Session object). */
private states = new Map<string, SessionState>()
private states = new Map<SessionId, SessionState>()
/** Write-behind buffers keyed by the live Session (write path). */
private buffers = new Map<Session, SessionEvent[]>()
/**
* 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<string, Promise<unknown>>()
private chains = new Map<SessionId, Promise<unknown>>()
/**
* 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
@@ -85,7 +85,7 @@ async function liveSessionInFiber(
): Promise<Session> {
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)
+2 -1
View File
@@ -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<string>
pendingCalls: Set<CallId>
}
/**
@@ -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()
+2 -2
View File
@@ -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
+32 -26
View File
@@ -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<AcpConfig> = 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<string, SessionRecord>()
const bySession = new WeakMap<Agent, string>()
const sessions = new Map<SessionId, SessionRecord>()
const bySession = new WeakMap<Agent, SessionId>()
// 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<string>()
const loadingIds = new Set<SessionId>()
// 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<LoadSessionResponse> {
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<PromptResponse> {
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<void> {
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<ToolPresenter, 'call' | 'result'> = 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<string, { name: string; args: unknown; isTerminal: boolean }>()
private readonly pending = new Map<CallId, { name: string; args: unknown; isTerminal: boolean }>()
/**
* @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.
+5 -4
View File
@@ -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')
})
+28 -27
View File
@@ -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()
})
})
+3 -1
View File
@@ -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))
+5 -4
View File
@@ -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/)
})
+3 -2
View File
@@ -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).
+2 -2
View File
@@ -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
}
+5 -5
View File
@@ -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
}
+3 -2
View File
@@ -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
+26
View File
@@ -0,0 +1,26 @@
# dsh-brand
The `Branded<B>` 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`.
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@deepseek-ai/dsh-brand",
"description": "Type-only Branded<B> 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"
}
}
+27
View File
@@ -0,0 +1,27 @@
/**
* The `Branded<B>` 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<B extends string> = string & { readonly [BRAND]: B }
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib"
},
"include": [
"src"
],
"references": []
}
+18
View File
@@ -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':
+1 -1
View File
@@ -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" },
+1
View File
@@ -45,6 +45,7 @@
"./packages/bash/*/src",
"./packages/session-persistence/*/src",
"./packages/ui/*/src",
"./packages/util/*/src",
"./packages/support/*/src"
]
}
+1
View File
@@ -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" },
+1
View File
@@ -22,6 +22,7 @@
"./packages/bash/*/src",
"./packages/session-persistence/*/src",
"./packages/ui/*/src",
"./packages/util/*/src",
"./packages/support/*/src"
]
}