From 709cc7200eb2b3ac64090c67833a25a367a1b3df Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 01:59:21 +0800 Subject: [PATCH] refactor: unify agent and session identity --- docs/config-catalog.md | 18 +- docs/cookbook/extension-cookbook.md | 4 +- docs/cordis-catalog/events.md | 34 +-- docs/cordis-catalog/services.md | 12 +- docs/core-data-structures/approval.md | 2 +- docs/core-data-structures/bash.md | 2 +- docs/core-data-structures/core.md | 8 +- docs/core-data-structures/subagent.md | 2 +- docs/event-producer-consumer.md | 34 +-- .../2026-06-11-content-block-vocabulary.md | 2 +- .../2026-06-14-session-persistence.md | 2 +- .../architecture/2026-06-20-branded-ids.md | 10 +- .../2026-06-30-event-domain-semantics.md | 2 +- .../2026-07-08-agent-scope-contexts.md | 3 +- .../feature/2026-07-08-repeat-tool-guard.md | 5 +- ...-20-remove-agent-boundary-mirror-events.md | 8 +- ...claude-code-and-codex-subagent-backends.md | 2 +- .../2026-06-20-unify-agent-and-session-id.md | 6 +- examples/coding-agent/tests/code-mode.e2e.ts | 7 +- .../coding-agent/tests/coding-task.e2e.ts | 4 +- examples/coding-agent/tests/compaction.e2e.ts | 4 +- examples/coding-agent/tests/full-loop.e2e.ts | 4 +- examples/coding-agent/tests/resume.e2e.ts | 3 - examples/coding-agent/tests/todo-write.e2e.ts | 4 +- .../cordis-agent/tests/cordis-tools.e2e.ts | 8 +- .../bash/tool-bash/tests/integration.spec.ts | 11 +- packages/bash/tool-bash/tests/tools.spec.ts | 2 +- .../tests/compact-loop-repro.spec.ts | 7 +- .../cordis/tool-cordis/src/api-catalog.ts | 16 +- .../tool-cordis/tests/integration.spec.ts | 7 +- packages/core/agent-core/README.md | 2 +- .../core/agent-core/tests/agent-core.spec.ts | 12 +- packages/core/agent-loop/README.md | 10 +- packages/core/agent-loop/src/agent.ts | 8 +- packages/core/agent-loop/src/index.ts | 29 ++- packages/core/agent-loop/tests/agent.spec.ts | 53 +++-- packages/core/agent-loop/tests/cancel.spec.ts | 35 ++-- .../tests/config-session-id.spec.ts | 29 +-- .../agent-loop/tests/coverage-edges.spec.ts | 21 +- .../agent-loop/tests/interception.spec.ts | 62 +++--- packages/core/agent-loop/tests/loop.spec.ts | 85 ++++---- .../core/agent-loop/tests/properties.spec.ts | 11 +- .../agent-loop/tests/request-cache.e2e.ts | 7 +- .../tests/request-reconstruction.spec.ts | 22 +- packages/core/agent-loop/tests/resume.spec.ts | 85 ++++---- .../agent-loop/tests/review-fixes.spec.ts | 83 ++++---- .../agent-loop/tests/scope-lifecycle.spec.ts | 193 ++++++++---------- .../core/agent-loop/tests/tool-order.spec.ts | 9 +- .../core/agent-loop/tests/turn-stop.spec.ts | 19 +- packages/core/agent/README.md | 2 +- packages/core/agent/src/index.ts | 28 ++- packages/core/agent/src/types.ts | 20 +- packages/core/agent/tests/agent.spec.ts | 29 +-- packages/core/session/src/index.ts | 7 +- packages/core/tools/tests/code-mode.spec.ts | 3 +- packages/core/tools/tests/scoped.spec.ts | 14 +- packages/fs/tool-fs/tests/fs-tools.e2e.ts | 4 +- packages/guard/repeat-tool-guard/README.md | 4 +- packages/guard/repeat-tool-guard/src/index.ts | 19 +- .../tests/repeat-tool-guard.spec.ts | 39 ++-- .../hooks/hooks-claude/tests/bridge.spec.ts | 31 +-- .../hooks/hooks-claude/tests/coverage.spec.ts | 73 +++---- .../hooks/hooks-codex/tests/bridge.spec.ts | 17 +- .../hooks/hooks-codex/tests/coverage.spec.ts | 73 +++---- packages/subagent/subagent-acp/src/run.ts | 4 +- .../tests/multi-subagent.spec.ts | 7 +- .../subagent-fork/tests/subagent-fork.spec.ts | 7 +- .../subagent/subagent-inprocess/src/index.ts | 7 +- .../tests/structured.spec.ts | 9 +- .../tests/subagent-inprocess.spec.ts | 7 +- .../subagent-spawn/tests/spawn.e2e.ts | 4 +- .../tests/subagent-spawn.spec.ts | 13 +- packages/subagent/subagent/src/index.ts | 7 +- packages/subagent/subagent/src/types.ts | 5 +- .../subagent/subagent/tests/service.spec.ts | 12 +- .../tool-subagent/tests/tool-subagent.spec.ts | 24 ++- packages/support/subagent-mock/src/index.ts | 4 +- .../subagent-mock/tests/subagent-mock.spec.ts | 6 +- .../todo/tool-todo/tests/integration.spec.ts | 9 +- .../todo/tool-todo/tests/tool-todo.spec.ts | 5 +- packages/ui/acp/src/index.ts | 3 - packages/ui/acp/tests/approval.spec.ts | 6 +- packages/ui/acp/tests/bridge.spec.ts | 24 +-- packages/ui/acp/tests/dispose.spec.ts | 43 ++-- packages/ui/acp/tests/edges.spec.ts | 3 +- packages/ui/acp/tests/load.spec.ts | 9 +- packages/ui/acp/tests/multi-session.spec.ts | 6 +- packages/ui/acp/tests/turns.spec.ts | 6 +- packages/ui/jsonrpc/src/server.ts | 2 - packages/ui/jsonrpc/tests/server.spec.ts | 27 +-- packages/ui/stdio-agent/README.md | 8 +- packages/ui/stdio-agent/src/index.ts | 23 +-- packages/ui/stdio-agent/src/stdio-chat.ts | 40 ++-- .../ui/stdio-agent/tests/readline.spec.ts | 2 +- .../ui/stdio-agent/tests/stdio-agent.spec.ts | 16 +- .../ui/stdio-agent/tests/stdio-chat.spec.ts | 60 +++--- packages/util/brand/README.md | 4 +- packages/util/brand/src/index.ts | 8 +- .../tool-workflow/tests/tool-workflow.spec.ts | 6 +- .../workflow-workerthread/src/runtime.ts | 4 +- .../tests/integration.spec.ts | 9 +- .../tests/source-worker.compat.spec.ts | 4 +- .../tests/workflow-workerthread.e2e.ts | 8 +- .../tests/workflow-workerthread.spec.ts | 20 +- packages/workflow/workflow/src/types.ts | 5 +- 105 files changed, 899 insertions(+), 948 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9d4b8038b5..94d613bc4e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -31,7 +31,7 @@ export interface AcpConfig { Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/ui/acp/src/index.ts:250`](../packages/ui/acp/src/index.ts) +Source: [`packages/ui/acp/src/index.ts:249`](../packages/ui/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-agent` @@ -117,8 +117,8 @@ Requires: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` export interface Config { /** Agents created or resumed at plugin startup. */ agents: (AgentOptions & { - /** Registry identity for the live agent. */ - id: AgentId + /** Stable config label used in logs and as the fresh combined-id prefix. */ + id: string /** Optional workspace for a fresh session. */ cwd?: string /** Persisted session to resume instead of creating a fresh session. */ @@ -127,9 +127,9 @@ export interface Config { } ``` -Depends on: [`AgentId`](../packages/core/agent/src/index.ts) · [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts) +Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts) -Source: [`packages/core/agent-loop/src/index.ts:325`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:324`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -611,7 +611,7 @@ Source: [`packages/skill/skill-local/src/index.ts:39`](../packages/skill/skill-l ```ts config-catalog /** * App config: the swappable per-demo values, each routed to where the app wires - * it. `model`/`resumeSessionId` configure the pre-created `main` agent (through + * it. `model`/`resumeSessionId` configure the pre-created agent (through * {@link @deepseek-ai/dsh-agent-core}'s forwarded `agents` list); `persona` is * the deployment persona (forwarded to the system-prompt plugin); `toolOrder` * is the explicit model-facing tool order (forwarded to the system-prompt plugin); @@ -620,7 +620,7 @@ Source: [`packages/skill/skill-local/src/index.ts:39`](../packages/skill/skill-l * `welcome` is the UI banner. */ export interface Config { - /** Model name for the `main` agent (must have a registered adapter). */ + /** Model name for the pre-created agent (must have a registered adapter). */ model: string /** Deployment persona (the system-prompt plugin's `persona` config). */ persona?: string @@ -635,7 +635,7 @@ export interface Config { /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */ skills?: agentCore.SkillConfig /** - * If set, the `main` agent RESUMES this persisted session id instead of + * If set, the pre-created agent RESUMES this persisted session id instead of * starting fresh. Sourced from an env var in the leaf `cordis.yml` * (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`). */ @@ -645,7 +645,7 @@ export interface Config { Depends on: [`agentCore`](../packages/core/agent-core/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) -Source: [`packages/ui/stdio-agent/src/index.ts:65`](../packages/ui/stdio-agent/src/index.ts) +Source: [`packages/ui/stdio-agent/src/index.ts:64`](../packages/ui/stdio-agent/src/index.ts) ## `@deepseek-ai/dsh-subagent-acp` diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index e2677e463a..1db5e85828 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -36,7 +36,7 @@ A UI plugin renders from the `session/event` feed (the assistant token stream as ```ts import type { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' declare function render(text: string): void declare function onUserInput(handler: (text: string) => void): void @@ -50,7 +50,7 @@ export function apply(ctx: Context) { render(event.data.chunk.text) } }) - onUserInput(text => ctx.agents.get(AgentId('main'))?.send([{ type: 'text', text }])) + onUserInput(text => ctx.agents.get(SessionId('client-session'))?.send([{ type: 'text', text }])) } ``` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 5a3398f9a8..a5e018079a 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -23,7 +23,7 @@ An agent's fully composed scoped world was published in the AgentRegistry. Its s Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:316`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:304`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -35,7 +35,7 @@ An agent was removed from the registry. The concrete AgentLoop lifecycle emits t Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:331`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:319`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -47,7 +47,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:605`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:593`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -61,7 +61,7 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:438`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:426`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -73,7 +73,7 @@ Waterfall: decide what happens to ONE drained queued message before it becomes a 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:456`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:444`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -85,7 +85,7 @@ A message entered the agent's inbox (queued or steering). Content and 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:360`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:348`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -97,7 +97,7 @@ Waterfall: shape the step's call configuration — model switching, sampling ove Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:485`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:473`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -113,7 +113,7 @@ The seed is a frozen empty list; a contributing listener returns a NEW array — Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:537`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:525`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -125,7 +125,7 @@ The agent's session lifecycle began, fired once before its first turn. `source` Types: [Agent](../core-data-structures/core.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:381`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:369`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -137,7 +137,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:345`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -149,7 +149,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:552`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:540`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -161,7 +161,7 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:570`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:558`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -173,7 +173,7 @@ Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` wat Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:588`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:576`](../../packages/core/agent/src/types.ts) ## `approval/*` @@ -317,7 +317,7 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c 'subagent/end'(this: Scoped, info: SubagentRunEndInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:90`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:91`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit @@ -327,7 +327,7 @@ A provider became resolvable in the registry. 'subagent/provider-added'(provider: SubagentProvider): void ``` -Source: [`packages/subagent/subagent/src/index.ts:66`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:67`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-removed` — emit @@ -337,7 +337,7 @@ A provider left the registry. Accepted runs remain holder-owned. 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:72`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:73`](../../packages/subagent/subagent/src/index.ts) ### `subagent/start` — emit @@ -347,7 +347,7 @@ A provider established a ready child. For in-process providers, `ctx.agents.get( 'subagent/start'(this: Scoped, info: SubagentRunInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:82`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:83`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index bfb31749d5..4d162a55f5 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -14,12 +14,12 @@ The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary Concrete ReactLoopAgent factory and driver service. ```ts cordis-catalog -create(id: AgentId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent +create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:338`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:337`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry` @@ -32,13 +32,13 @@ async resume(options: ResumeAgentOptions): Promise register(agent: Agent): () => void enter(agent: Agent): () => void announce(agent: Agent): void -get(id: AgentId): Agent | undefined +get(id: SessionId): Agent | undefined list(): Agent[] ``` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:203`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:199`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` @@ -218,7 +218,7 @@ list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Source: [`packages/core/session/src/index.ts:592`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:597`](../../packages/core/session/src/index.ts) ## `ctx.skills` — `SkillService` @@ -244,7 +244,7 @@ list(): string[] async start(name: string, request: SubagentStartRequest): Promise ``` -Source: [`packages/subagent/subagent/src/index.ts:123`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:124`](../../packages/subagent/subagent/src/index.ts) ## `ctx.systemPrompt` — `SystemPrompt` diff --git a/docs/core-data-structures/approval.md b/docs/core-data-structures/approval.md index c5fa1fe13b..e8820ccfeb 100644 --- a/docs/core-data-structures/approval.md +++ b/docs/core-data-structures/approval.md @@ -6,7 +6,7 @@ Source: [`packages/ui/user-approval/src/index.ts`](../../packages/ui/user-approv ## Identity and outcome -Every request receives a fresh `ApprovalRequestId`. The brand pairs the `approval/asked` and `approval/decided` audit events without making approval ids interchangeable with tool-call, session, or agent ids. +Every request receives a fresh `ApprovalRequestId`. The brand pairs the `approval/asked` and `approval/decided` audit events without making approval ids interchangeable with tool-call or agent/session ids. ```ts type-equiv type ApprovalRequestId = Branded<'ApprovalRequestId'> diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index 51f7cb0696..df6c5ab16b 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -110,7 +110,7 @@ The `owner` token is the isolation key: the executor stores it but never interpr `stdin` and `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool does not expose them as parameters — its request is built from `command`/`workdir`/`timeoutMs`/`signal`/`owner` only — because a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), so duplicating them as tool params would be redundant. This is NOT a security boundary: the credential scrub in `dsh-bash-local` is what stops the harness's ambient secrets reaching a spawned command, and it works regardless of these fields (a model cannot read a value the scrub removed, and tool-call args are static JSON, never shell-evaluated). A guard test asserts the tool doesn't forward model `env`/`stdin` — to catch a future `...args` spread, not to defend a trust wall. `env` is merged AFTER the scrub so an explicit caller entry (a value it already holds) wins even on a credential-shaped name. See [the bash-stdin-env RFC](../rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). -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. +Both ids the seam handles are [branded](core.md) (zero-cost `string` brands, the same machinery as `SessionId`): `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` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 168d727c8d..c6b74ed3c0 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -73,7 +73,7 @@ Two large discriminated unions are the ones consumers `switch` over most: **`Str ## Branded IDs -IDs that cross package boundaries are **branded** — structurally strings, but non-interchangeable at the type level (an `AgentId` can't be passed where a `CallId` is expected). Construction goes through a per-type factory; comparison, logging, and JSON behave as ordinary strings. +IDs that cross package boundaries are **branded** — structurally strings, but non-interchangeable at the type level (a `SessionId` cannot be passed where a `CallId` is expected). Construction goes through a per-type factory; comparison, logging, and JSON behave as ordinary strings. The `Branded` primitive lives in its own type-only package, [dsh-brand](../../packages/util/brand) (no runtime code, no harness-package dependency), so any package can brand the ids it owns without depending on an unrelated capability package (e.g. dsh-bash brands `BashTaskId`/`OwnerToken` via dsh-brand alone, never pulling in dsh-llm). @@ -83,7 +83,7 @@ Source: [`packages/util/brand/src/index.ts`](../../packages/util/brand/src/index type Branded = string & { readonly [BRAND]: B } ``` -The three core IDs: `CallId` (correlates a tool call with its result; dsh-llm), `SessionId` (dsh-session), `AgentId` (dsh-agent). Each is `Branded<'CallId'>` etc. plus a same-named factory function. Capability seams brand their own ids too — see `BashTaskId`/`OwnerToken` in [bash.md](bash.md). +The two core IDs are `CallId` (correlates a tool call with its result; dsh-llm) and `SessionId` (the shared live agent and durable session identity; dsh-session). Each is a `Branded<'…'>` 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 @@ -254,7 +254,7 @@ Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types ```ts type-equiv interface Agent { - readonly id: AgentId + readonly id: SessionId readonly options: AgentOptions readonly session: Session readonly status: AgentStatus @@ -353,7 +353,7 @@ interface Agent { } ``` -`AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`) is merge-extensible — plugins add creation options by declaration merging. Persona is not an agent option: the `dsh-system-prompt` config supplies the global default, and an agent-scoped `deployment:persona` section may shadow it. The `agent/*` event taxonomy (lifecycle emits incl. `agent/session-start`, serial `agent/pre-step`/`agent/turn-stop` checkpoints, and the `agent/prompt-submit`/`agent/request`/`agent/session-prefix`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy); turn/step boundaries are durable `session/event` records, not `agent/*` emits. +`AgentStatus` is `'idle' | 'running' | 'disposed'`. `Agent.id` and `Agent.session.id` are the same branded `SessionId`. `AgentOptions` (`model?`) is merge-extensible — plugins add creation options by declaration merging. Persona is not an agent option: the `dsh-system-prompt` config supplies the global default, and an agent-scoped `deployment:persona` section may shadow it. The `agent/*` event taxonomy (lifecycle emits incl. `agent/session-start`, serial `agent/pre-step`/`agent/turn-stop` checkpoints, and the `agent/prompt-submit`/`agent/request`/`agent/session-prefix`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy); turn/step boundaries are durable `session/event` records, not `agent/*` emits. ## Interception decisions diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 5454f326b3..883c33020a 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -68,7 +68,7 @@ The handle the consumer holds after a provider has established a ready child. Th ```ts type-equiv interface SubagentRun { - readonly id: AgentId + readonly id: SessionId readonly result: Promise dispose(): Promise sendMessage?(content: ContentBlock[]): void diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index a7e2c8bb37..80389608cc 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,19 +7,19 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:331`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:605`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:438`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:456`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:360`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:485`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:537`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:381`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:345`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:552`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:570`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:588`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:319`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:593`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:426`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:444`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:348`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:473`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:525`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:369`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:333`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:540`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:558`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:576`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:70`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | @@ -31,10 +31,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:100`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `skill/provider-added` | `emit` | [`packages/skill/skill/src/index.ts:131`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | | `skill/provider-removed` | `emit` | [`packages/skill/skill/src/index.ts:137`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:90`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:66`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:72`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:82`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:91`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:67`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:73`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:83`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:49`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | - | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:59`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:173`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | diff --git a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md index 42efb34774..fb272c5df9 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md +++ b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md @@ -21,4 +21,4 @@ In-session context injection (`context/message`, `steering/message`) renders as - Reasoning has a home without provider contortions. Multimodal content deliberately has NO core block type: the core set is limited to blocks every shipping path honors, and a multimodal feature adds its block type through the merge-extensible map in the same coordinated change that maps it in the adapters, surfaces it in the UI bridges, and prices it in compaction — see [the drop-image RFC](../simplification/2026-07-04-drop-image-content-block.md). Block cache hints likewise have no core field: DeepSeek prompt caching is automatic, so no shipping adapter can transmit a hint; a caching feature adds a `cache` field together with the adapter that honors it — see [the producer-less-variants RFC](../simplification/2026-07-04-prune-producerless-vocabulary-variants.md). Assistant-prefix continuation (prefill) likewise has no request field: DeepSeek's chat-prefix completion is a Beta feature on a base URL neither shipping adapter targets, so a prefill feature adds `GenerateOptions.prefill` together with the adapter that honors it — see [the inert-request-knobs RFC](../simplification/2026-07-04-drop-inert-request-knobs.md). - Every adapter pays a translation cost; the first real adapters have since validated the streaming protocol, and new adapters should continue proving their provider-specific mapping in adapter-local tests. -- IDs that cross package boundaries are branded (`CallId`, `SessionId`, `AgentId`) — nominal typing at zero runtime cost. +- IDs that cross package boundaries are branded (`CallId`, the shared agent/session `SessionId`) — nominal typing at zero runtime cost. diff --git a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md index 03d8a30b37..824e59a172 100644 --- a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md +++ b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md @@ -23,7 +23,7 @@ Key choices recorded here because they are durable, contested, and surprising: - **Append-only; a crashed turn is closed, never truncated.** Committed events — those at or below a flushed `turn/end` — are never rewritten. The loop only flushes at `turn/end`, so a crash can leave a durable log whose final turn never closed: real, fully-written events sit after the last `turn/end`. **A single turn can be huge in a long-horizon task** (many steps, large tool output spanning a long autonomous run), so discarding the interrupted turn would silently destroy a large amount of real work — truncating a turn is wrong. Instead, on reload `load` PRESERVES those events and CLOSES the orphaned turn by durably appending the minimal synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered, then a `step/end` if a step was still open, then a `turn/end` carrying the merge-extensible `{ kind: 'interrupted' }` reason (a marker that records the turn was cut short by a crash, not completed by the model — no loop ever emits it). The synthetic tool results matter for resume correctness: the loop logs the `assistant/message` (carrying the `tool-call` blocks) BEFORE running the tools, so a crash mid-tool leaves calls without results; `deriveMessages()` would then replay a dangling assistant tool-call, which every provider rejects as an invalid transcript on the next request. Answering each orphaned call with an error result keeps the rehydrated history valid. `load` returns the balanced log, so a resumed session is immediately usable. The ONLY thing discarded is a never-fully-written **torn tail fragment** — a final record whose bytes (JSONL) or row were never completely flushed; that fragment is not a valid event and is dropped before the synthetic closers are written. A parse error or `seq` gap in the COMMITTED region (at or before the last real `turn/end`) is genuine corruption and makes the session unloadable. - **File backend canonical, DB backend a proven drop-in.** `SessionEvent` maps 1:1 onto a row `(session_id, seq, type, time, data)` — `append` is INSERT (in a transaction asserting the contiguous-seq contract), `load` is SELECT … ORDER BY seq. `dsh-session-persistence-sqlite` is exactly this: a `SessionPersistence` subclass with no interface change (opencode runs this exact shape on SQLite/WAL), and it passes the same `runPersistenceContract` suite as the JSONL backend — so the contract holds both backends to identical semantics (lazy materialization, interrupted-turn close on load, contiguous-seq), expressed once over file bytes and once over rows. - **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header seam is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../simplification/2026-06-19-drop-mutable-session-summary.md).) -- **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` awaits `ctx.sessionPersistence.load`, recreates the live session with the loaded events (so `lastTurnNumber`/`deriveMessages` continue), and starts a fresh agent on the resumed id (NOT `${agentId}-session`). The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent. +- **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` awaits `ctx.sessionPersistence.load`, recreates the live session with the loaded events (so `lastTurnNumber`/`deriveMessages` continue), and registers the fresh agent under the exact resumed id. The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent. ## Alternatives considered diff --git a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md index 8171a05d30..5fe8224644 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md +++ b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md @@ -4,23 +4,23 @@ Status: implemented ## Problem -The harness already brands three identifiers — `CallId` (`packages/llm/llm/src/brand.ts`), `SessionId` (`packages/core/session/src/types.ts`), and `AgentId` (`packages/core/agent/src/types.ts`) — using the `Branded = string & { readonly [BRAND]: B }` machinery (owned by the type-only `@deepseek-ai/dsh-brand` package at `packages/util/brand/` — see its [README](../../../../packages/util/brand/README.md)) and a zero-cost cast factory per type. `dsh-brand` also states the governing policy: *"Branding is for ids that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker today. +The harness brands `CallId` (`packages/llm/llm/src/brand.ts`) and the shared agent/session `SessionId` (`packages/core/session/src/types.ts`) using the `Branded = string & { readonly [BRAND]: B }` machinery (owned by the type-only `@deepseek-ai/dsh-brand` package at `packages/util/brand/` — see its [README](../../../../packages/util/brand/README.md)) and a zero-cost cast factory per type. `dsh-brand` also states the governing policy: *"Branding is for ids that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker today. **Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-task id is a plain `string`: `BashTask.id: string` (`packages/bash/bash/src/types.ts`), carried as `string` through the whole executor seam (`BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/bash/bash/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateTaskId`, `assertTaskAccess`, the `task_id` schema arg in `packages/bash/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/bash/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/core/session/src/index.ts`). A bash task id and a session id are trivially swappable at a call site and the compiler says nothing. This is the headline case the user asked about, and it is a model-facing id (the model passes `task_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input. The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` (`packages/bash/bash/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's `session.header.id` (`callerToken = (exec) => exec.agent?.session.header.id` in `packages/bash/tool-bash/src/index.ts`) — i.e. a `SessionId` wearing a `string` disguise. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the same `session.header.id`-as-owner alias that the [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) proposal calls the "bash owner-token alias hole". -**Gap 2 — brand erosion at the seams of the *already-branded* IDs.** Even `CallId`/`SessionId`/`AgentId` decay back to bare `string` at exactly the places confusion is most likely: the registry/store `Map` key types and most public method params. Representative sites: `SessionStore.store = new Map()` and `create`/`prepare(id?: string)`/`get(id: string)` (`packages/core/session/src/index.ts`); `AgentRegistry.store = new Map()` and `register`/`get(id: string)` (`packages/core/agent/src/index.ts`); `ToolPresenter.pending = new Map()` keyed by call id and `call(callId: string)`/`result(callId: string)` (`packages/ui/acp/src/index.ts`); the ACP session-id surface beyond the store map — `SessionRecord.sessionId: string`, `bySession = new WeakMap()`, `loadingIds = new Set()`, `requireSession(sessionId: string)`, and the exported `streamSessionEventUpdate(sessionId: string, …)` (`packages/ui/acp/src/index.ts`); and the persistence coordinator's `Map` keyed by session id (`packages/session-persistence/session-persistence/src/coordinator.ts`). A brand that is dropped at the `Map` key buys nothing on lookups — the value of the existing brands is partly unrealized. +**Gap 2 — brand erosion at the seams of the *already-branded* IDs.** Even `CallId` and `SessionId` decay back to bare `string` at exactly the places confusion is most likely: registry/store key types and public method params. Representative sites include the session store, the agent registry (both keyed by the shared `SessionId`), `ToolPresenter`'s call-id map, ACP's session-id records and loading set, and the persistence coordinator. A brand that is dropped at a collection key buys nothing on lookups — the value of the existing brands is partly unrealized. ## Decision A type-only change. Brands are zero-cost casts; nothing about runtime behavior, serialization, comparison, or the wire format changes. The work is in three parts, all honoring the existing "not every string" policy. -- **Brand the bash task id.** Add `BashTaskId = Branded<'BashTaskId'>` plus its same-named factory in `packages/bash/bash/src/types.ts` (the package that *owns* the id), importing `Branded` from `@deepseek-ai/dsh-brand` exactly as `SessionId`/`AgentId` already do. The brand primitive lives in the dependency-free `dsh-brand` utility package precisely so `dsh-bash` can brand its ids by depending on it alone — it never pulls in `dsh-llm` (or `dsh-session`) just to reach `Branded`. Thread it through `BashTask.id`, the `BashExecutor` seam methods (`get`/`ownerOf`/`readOutput`/`kill`), the generation site in `dsh-bash-local` (brand the counter output once, at creation), and the `dsh-tool-bash` validate/access surface (`validateTaskId` returns a `BashTaskId`; `task_id` is branded at the tool boundary where the model's string arrives). +- **Brand the bash task id.** Add `BashTaskId = Branded<'BashTaskId'>` plus its same-named factory in `packages/bash/bash/src/types.ts` (the package that *owns* the id), importing `Branded` from `@deepseek-ai/dsh-brand` exactly as `SessionId` does. The brand primitive lives in the dependency-free `dsh-brand` utility package precisely so `dsh-bash` can brand its ids by depending on it alone — it never pulls in `dsh-llm` (or `dsh-session`) just to reach `Branded`. Thread it through `BashTask.id`, the `BashExecutor` seam methods (`get`/`ownerOf`/`readOutput`/`kill`), the generation site in `dsh-bash-local` (brand the counter output once, at creation), and the `dsh-tool-bash` validate/access surface (`validateTaskId` returns a `BashTaskId`; `task_id` is branded at the tool boundary where the model's string arrives). - **Mint a distinct `OwnerToken` brand.** Add `OwnerToken = Branded<'OwnerToken'>` in `packages/bash/bash/src/types.ts`; type `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` as `OwnerToken | undefined`. The `dsh-tool-bash` consumer casts the agent's `session.header.id` (a `SessionId`) into an `OwnerToken` at the boundary — the one place the two vocabularies meet. The bash seam never imports `dsh-session`. (Rationale in the next section.) -- **Stop the brand erosion.** Propagate the existing brands to the `Map` key types and public method params listed under Gap 2 — `Map`, `get(id: SessionId)`, `Map`, `Map`, the ACP `SessionRecord.sessionId: SessionId` surface, the coordinator's `Map`. This is the larger mechanical share of the diff and the part that makes the *existing* brands actually load-bearing on lookups, not just on the struct fields. +- **Stop the brand erosion.** Propagate the existing brands to the `Map` key types and public method params listed under Gap 2 — `Map`, `Map`, `get(id: SessionId)`, `Map`, ACP's `SessionId` surface, and the coordinator's `Map`. This is the larger mechanical share of the diff and the part that makes the *existing* brands actually load-bearing on lookups, not just on struct fields. Illustrative shape (the factory pattern is identical to the three existing brands): @@ -58,7 +58,7 @@ Kept deliberately narrow per the "not every string needs a brand" policy. Each o ## Verification -The landed invariants: `BashTaskId` and `OwnerToken` are defined in `dsh-bash` and threaded end-to-end (executor seam, the `dsh-bash-local` generation site, the `dsh-tool-bash` model-facing surface) with no `dsh-bash` dependency on `dsh-session`; no collection keyed by an in-scope branded id (`CallId`/`SessionId`/`AgentId`/`BashTaskId`) is keyed by bare `string` — `Map` keys, `WeakMap` value slots, `Set` membership (the ACP `bySession`/`loadingIds`), public method params, and exported signatures (`streamSessionEventUpdate`) all take the brand; and brands are constructed via the cast factory at each boundary where a raw string enters (provider call id, ACP session id, model-supplied `task_id`), never as scattered `as` casts. +The landed invariants: `BashTaskId` and `OwnerToken` are defined in `dsh-bash` and threaded end-to-end (executor seam, the `dsh-bash-local` generation site, the `dsh-tool-bash` model-facing surface) with no `dsh-bash` dependency on `dsh-session`; no collection keyed by an in-scope branded id (`CallId`/`SessionId`/`BashTaskId`) is keyed by bare `string`; public method params and exported signatures keep the brand; and brands are constructed via the cast factory at each boundary where a raw string enters (provider call id, ACP session id, model-supplied `task_id`), never as scattered `as` casts. ## Consequences diff --git a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md index f0ab95ca80..f785851237 100644 --- a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md +++ b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md @@ -24,7 +24,7 @@ This is the foundational change in a stack that adds a Hooks subsystem; it estab **The boundary rule:** a durable, replayable fact is a `SessionEvent`; a live interception or a transient/live-object signal is an `agent`/`tools` Cordis event. A turn or step boundary is a durable fact, so it lives in the session log and is read off the `session/event` feed — it is NOT mirrored as an `agent/*` emit. -**Applying the rule to the boundary twins:** all four boundary mirrors — `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end` — are **REMOVED**. No production consumer needs the live `Agent` at a boundary: the ACP bridge settles from `session/event` `turn/end` plus `agent/status`, and the only turn-mirror consumer (`dsh-ui-stdio`, a disposable test REPL) was migrated to render boundaries from `session/event`, recovering the short agent label from an `agent/created`→id map. The step mirrors were removed first (they had no consumer at all); the turn mirrors followed once ui-stdio was migrated — see [the remove-boundary-mirror-events RFC](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md), which owns that decision. Removing the emits also simplifies the loop's `closeStep`/`closeTurn` (one append each, no paired emit). +**Applying the rule to the boundary twins:** all four boundary mirrors — `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end` — are **REMOVED**. No production consumer needs the live `Agent` at a boundary: the ACP bridge settles from `session/event` `turn/end` plus `agent/status`, and the only turn-mirror consumer (`dsh-ui-stdio`, a disposable test REPL) renders boundaries from `session/event` while retaining its live target object for the fixed `main` label. The step mirrors were removed first (they had no consumer at all); the turn mirrors followed once ui-stdio was migrated — see [the remove-boundary-mirror-events RFC](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md), which owns that decision. Removing the emits also simplifies the loop's `closeStep`/`closeTurn` (one append each, no paired emit). ## Consequences diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md index dcff4d3220..15477e023a 100644 --- a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md +++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -60,8 +60,7 @@ The ordinary contributor pattern is to register the complete local world during ```js const handle = await ctx.agents.create({ - agentId: AgentId('reviewer'), - sessionId: SessionId('reviewer-session'), + sessionId: SessionId('reviewer'), agentOptions: { model: 'model-name' }, setup(agentCtx) { agentCtx.systemPrompt.section({ diff --git a/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md index 9d0446dcad..6961883318 100644 --- a/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md +++ b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md @@ -12,11 +12,10 @@ The harness already has every seam the pi extension uses, and better ones: [the The guard is a loop-hygiene plugin, not a model-facing tool: it never appears in the tool list, never vetoes or rewrites a call, and adds exactly one behavior — it watches each agent's stream of tool calls, counts runs of consecutive calls to the same tool with identical canonicalized arguments, and at configured run lengths injects an escalating advisory reminder telling the model to stop repeating itself, re-read the last result, and either change approach or conclude. The purpose is to break unproductive loops within a few wasted steps instead of letting them run to the turn's natural end — while leaving the decision (retry differently, gather more evidence, or finish) entirely with the model, so a legitimately repeated call is delayed by nothing and blocked by nothing. -The plugin is `@deepseek-ai/dsh-repeat-tool-guard` at `packages/guard/repeat-tool-guard/`, opening the `guard/` group for loop-hygiene plugins (single-package groups have precedent: [the todo-write RFC](2026-06-29-todo-write-tool.md) shipped `todo/tool-todo`). It registers three listeners and holds all state in plugin-local maps keyed by `AgentId` — the tool registry is a context-level singleton whose waterfalls interleave every agent's calls (subagents run on the same context), so per-agent keying is correctness, not polish. +The plugin is `@deepseek-ai/dsh-repeat-tool-guard` at `packages/guard/repeat-tool-guard/`, opening the `guard/` group for loop-hygiene plugins (single-package groups have precedent: [the todo-write RFC](2026-06-29-todo-write-tool.md) shipped `todo/tool-todo`). It registers two listeners and holds state in a `WeakMap` keyed by the live `Agent` object — the tool registry is a context-level singleton whose waterfalls interleave every agent's calls (subagents run on the same context), so per-agent keying is correctness, not polish; weak object keys also make a disposal-only cleanup listener unnecessary. - **`tools/post-execute` (waterfall)** — the one detection point. The listener receives `(exec, result)` together, so counting and reminder delivery need no cross-event pending map (the pi extension needs one only because its `tool_call`/`tool_result` hooks are separate events). It always delegates via `next()` and, when a threshold is hit, folds a reminder onto the downstream decision's `additionalContext` — the observe-and-enrich posture [the hooks bridges](2026-06-30-hook-bridges.md) already use, honoring the waterfall contract. Counting happens here rather than in `tools/pre-execute` because post-execute also runs for denied calls (`ToolRegistry.execute` routes a deny through the same pipeline), and a model hammering a denied call is exactly the loop worth breaking. - **`agent/prompt-submit` (waterfall)** — pure reset hook: delegate via `next()`, clear the submitting agent's chain. A user interjection changes the context; repetition across it is not a loop. -- **`agent/status` (emit)** — on `disposed`, drop the agent's state, bounding the maps over harness lifetime. ### Detection semantics @@ -25,7 +24,7 @@ The chain key is `(tool name, canonical arguments)`; a call identical to the pre Two deliberate rules, both documented in [the package README](../../../../packages/guard/repeat-tool-guard/README.md) because they are behavior a reader would otherwise guess at: - **Untracked calls are transparent to the chain.** A call excluded by `include`/`exclude` neither increments nor resets the counter, so `grep X → todo_write → grep X` still counts as two consecutive `grep X` when `todo_write` is excluded. This is what makes exclusion useful — bookkeeping tools interleaved into a loop must not launder it — and it is the pi extension's (undocumented) semantics, kept on purpose and written down. -- **Calls without an agent are ignored.** A direct `ctx.tools.execute()` caller (tests, non-loop consumers) has no model to remind and no `AgentId` to key on. +- **Calls without an agent are ignored.** A direct `ctx.tools.execute()` caller (tests, non-loop consumers) has no model to remind and no live agent object to key on. ### Reminder delivery diff --git a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md index dbef22f3a3..f4b49281ea 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md +++ b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md @@ -21,9 +21,9 @@ This duplication is not free. Every lifecycle change had to update the session e Make `session/event` the single live boundary/transcript stream. Consumers that render turns, tool calls, tool results, assistant messages, and durable boundaries subscribe to `session/event` and derive their UI from the same event vocabulary persistence uses. -The four durable-boundary mirrors — `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end` — are removed from the agent event taxonomy. A UI that wants the agent handle (or its short id) at a boundary keeps a small map from session id to agent id built from `agent/created`/`agent/disposed`; `dsh-ui-stdio` does exactly this to label its `[ turn N]` header, since the `turn/start` session event carries only the turn number. The canonical record remains the event-sourced session log. +The four durable-boundary mirrors — `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end` — are removed from the agent event taxonomy. A UI that wants the agent handle at a boundary retains the live target object from `agent/created`/`agent/disposed` and compares its session directly; `dsh-ui-stdio` uses this to label the app-owned agent's `[main turn N]` header while other sessions render their durable id. The canonical record remains the event-sourced session log. -The step mirrors (which had no consumer at all) were removed first, in [the event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md); that RFC KEPT the turn mirrors on the stated justification that the stdio UI needed the `Agent` handle at the turn boundary. This RFC finishes the job: `dsh-ui-stdio` is a disposable test REPL whose rendering can change freely, so "ui-stdio needs it" is not a reason to keep a mirror — it was migrated to `session/event` + the id map, and the turn mirrors were removed too. +The step mirrors (which had no consumer at all) were removed first, in [the event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md); that RFC KEPT the turn mirrors on the stated justification that the stdio UI needed the `Agent` handle at the turn boundary. This RFC finishes the job: `dsh-ui-stdio` is a disposable test REPL whose rendering can change freely, so "ui-stdio needs it" is not a reason to keep a mirror — it reads `session/event` and retains only its live target object. ## Scope: what is and isn't removed @@ -38,8 +38,8 @@ RETAINED — NOT durable-boundary mirrors, so out of scope for this decision: ## Alternatives considered - **Bundling `agent/steering` into the removal** — the original proposal's shape; narrowed out as scope creep: it mirrors the durable `steering/message` control record, not a boundary, and was removed by [its own later decision](2026-07-04-remove-agent-steering-mirror.md) (as was `agent/stream-chunk`, by [the stream-chunk-mirror RFC](2026-07-02-remove-stream-chunk-mirror.md)). -- **Keeping the turn mirrors for the stdio UI** — [the event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md)'s original stance; rejected here because `dsh-ui-stdio` is a disposable test REPL, not a load-bearing consumer, and it renders boundaries from `session/event` + the id map instead. +- **Keeping the turn mirrors for the stdio UI** — [the event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md)'s original stance; rejected here because `dsh-ui-stdio` is a disposable test REPL, not a load-bearing consumer, and it renders boundaries from `session/event` plus its live target object instead. ## Consequences -A plugin can no longer observe turn/step boundaries from a convenient `Agent`-first event. It must either subscribe to `session/event` or maintain a session-to-agent association. That is an acceptable trade: boundary consumers should not depend on a second event feed that can drift from the durable log. +A plugin can no longer observe turn/step boundaries from a convenient `Agent`-first event. It subscribes to `session/event` and, if it needs the live object, resolves the shared id through `ctx.agents` or retains the object it already owns. That is an acceptable trade: boundary consumers should not depend on a second event feed that can drift from the durable log. diff --git a/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md index 3de3c7d9be..911d28cf1f 100644 --- a/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md +++ b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md @@ -14,7 +14,7 @@ Two sibling provider packages, structural variants of the ACP backend, plus one - `@deepseek-ai/dsh-subagent-codex` — spawns `codex app-server` and drives one thread/turn over its JSON-RPC-over-stdio protocol with a hand-rolled newline-JSON client (~200–300 lines) in the package. - `@deepseek-ai/dsh-subagent-process` — a pure library (the `subagent-inprocess` precedent) extracting what `dsh-subagent-acp` already carries and both new backends need: the credential env scrub (`SENSITIVE_ENV_PATTERN`/`buildChildEnv`), the EOF → SIGTERM → SIGKILL dispose ladder, and new isolated-config-dir helpers (`mkdtemp` create, best-effort remove). The ACP backend migrates onto it; `bash-local`'s sibling copy is left alone to bound the change. -Both providers copy the ACP backend's seam posture verbatim: fresh child per `start`, exactly one prompt round-trip, capabilities all `false`, `inheritsParentContext: false`, `request.parent`/`request.agentOptions` ignored, `id = AgentId(randomUUID())`, `result` never rejects — child-level failure flattens to a stop reason and the original error goes to `ctx.logger` via an `onError` spec callback. Model exposure is zero new code: `dsh-tool-subagent` is loaded once per provider with a distinct `toolName` (`subagent_claude_code`, `subagent_codex`). No new session events are needed — the only model-visible artifact is the tool result, so reconstructability holds exactly as it did for ACP. To be explicit about the boundary: the session log reconstructs the model-visible transcript, not workspace mutation history — a child granted write access mutates files as an ambient side effect outside the log, exactly as the bash tools and the ACP backend already do; replay reproduces requests, not the disk. +Both providers copy the ACP backend's seam posture verbatim: fresh child per `start`, exactly one prompt round-trip, capabilities all `false`, `inheritsParentContext: false`, `request.parent`/`request.agentOptions` ignored, `id = SessionId(randomUUID())`, `result` never rejects — child-level failure flattens to a stop reason and the original error goes to `ctx.logger` via an `onError` spec callback. Model exposure is zero new code: `dsh-tool-subagent` is loaded once per provider with a distinct `toolName` (`subagent_claude_code`, `subagent_codex`). No new session events are needed — the only model-visible artifact is the tool result, so reconstructability holds exactly as it did for ACP. To be explicit about the boundary: the session log reconstructs the model-visible transcript, not workspace mutation history — a child granted write access mutates files as an ambient side effect outside the log, exactly as the bash tools and the ACP backend already do; replay reproduces requests, not the disk. ## Verified interface facts (pinned versions) diff --git a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md index 1a69f9ebe6..6e3eea1515 100644 --- a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md +++ b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md @@ -16,13 +16,13 @@ Session itself repeats the same fact as `Session.id` and `Session.header.id`. Co Make an agent's registry id equal its session id. `CreateAgentOptions` accepts one id used for both final registry entries; resume registers the agent under the resumed session id; subagent creation mints one combined id; Session keeps one identity home by deriving `id` from `header.id` or removing the alias. Keep the existing creation transaction, final-entry collision checks, and exact-entry detach semantics; remove only maps and fields whose sole job is translating between the ids. -The config-driven path must first settle its currently hidden resume-or-create policy. Today it uses a stable agent label and fresh UUID-suffixed session id to avoid colliding with a durable log on the next run. Under unification it must deliberately resume the fixed id, mint a fresh combined id, or expose an explicit policy; implementation must not pick silently. +The config-driven path keeps `agents[].id` as a stable configuration label, not a live routing identity. A fresh start mints the combined id `${label}-session-${randomUUID()}` so durable restarts do not collide; `resumeSessionId` instead supplies the exact combined identity to load and register. Logs may use the stable label while all live and durable lookups use the one `SessionId`. `agent/created` and `agent/disposed` remain outside this proposal. They are paired publication lifecycle events, not identity aliases; any later consumer-free removal needs its own proposal after a fresh search. ## Alternatives considered -**Keep separate routing and log identities.** The config-driven loop uses a stable configured agent id with a fresh UUID session on each fresh process start. That is a real use of the distinction: a stable routing/display label plus a new durable conversation. Unification can proceed only after choosing whether this path resumes a fixed identity, mints a combined per-run identity, or exposes the policy explicitly. If the stable label is a required product contract, reject this proposal rather than hiding it in another map. +**Keep separate routing and log identities.** A stable configured label plus a fresh durable conversation is useful, but it does not require two live identities: the label can remain configuration/display metadata while the combined per-run `SessionId` owns routing and persistence. Keeping two ids would preserve translation maps and permit impossible pairings without adding lifecycle capability. ## Acceptance criteria @@ -35,4 +35,4 @@ The config-driven path must first settle its currently hidden resume-or-create p ## Risks -This forecloses latent multi-session-actor and session-handoff designs, makes persisted client-chosen session identity the registry identity, and touches every factory fixture. The config restart decision is blocking, not mechanical. If separate routing identity is a real requirement, reject this RFC and retain the current caller-supplied pair plus final-entry arbitration. +This forecloses latent multi-session-actor and session-handoff designs, makes persisted client-chosen session identity the registry identity, and touches every factory fixture. If separate routing identity becomes a real requirement, it needs an explicit lifecycle design rather than an unconstrained caller-supplied pair. diff --git a/examples/coding-agent/tests/code-mode.e2e.ts b/examples/coding-agent/tests/code-mode.e2e.ts index 512688d88d..22446f0dc3 100644 --- a/examples/coding-agent/tests/code-mode.e2e.ts +++ b/examples/coding-agent/tests/code-mode.e2e.ts @@ -4,11 +4,12 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' @@ -72,7 +73,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p it('collapses the wire tool list to [run_code], bridges sub-calls, and returns curated output', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-code-mode-e2e-')) ctx = await codeModeHarness(workdir) - const agent = ctx.agentLoop.create(AgentId('e2e-code-mode'), { model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('e2e-code-mode'), { model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', diff --git a/examples/coding-agent/tests/coding-task.e2e.ts b/examples/coding-agent/tests/coding-task.e2e.ts index ce716bdb2c..e1ae9804ac 100644 --- a/examples/coding-agent/tests/coding-task.e2e.ts +++ b/examples/coding-agent/tests/coding-task.e2e.ts @@ -4,8 +4,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * The swebench-style smoke test: a real model fixes a real bug in a temp @@ -54,7 +54,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('coding task: fix a failing test expect(before.status).not.toBe(0) ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT }) - const agent = ctx.agentLoop.create(AgentId('e2e-task'), { model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('e2e-task'), { model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', diff --git a/examples/coding-agent/tests/compaction.e2e.ts b/examples/coding-agent/tests/compaction.e2e.ts index 854cf49d2a..a0e71aacc4 100644 --- a/examples/coding-agent/tests/compaction.e2e.ts +++ b/examples/coding-agent/tests/compaction.e2e.ts @@ -3,8 +3,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * The compaction smoke test: a real model runs a multi-step bash task with a @@ -64,7 +64,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa }, persistenceRoot: join(workdir, '.sessions'), }) - const agent = ctx.agentLoop.create(AgentId('e2e-compaction'), { model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('e2e-compaction'), { model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', diff --git a/examples/coding-agent/tests/full-loop.e2e.ts b/examples/coding-agent/tests/full-loop.e2e.ts index 8718139ced..9a01713222 100644 --- a/examples/coding-agent/tests/full-loop.e2e.ts +++ b/examples/coding-agent/tests/full-loop.e2e.ts @@ -3,8 +3,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * The first place a REAL model meets the REAL bash tool: the cheap canary @@ -28,7 +28,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('full loop: real model + real bas it('runs a bash command on request and reports its output', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-full-loop-e2e-')) ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT }) - const agent = ctx.agentLoop.create(AgentId('e2e-loop'), { model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('e2e-loop'), { model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }]) await waitForIdle(ctx, agent) diff --git a/examples/coding-agent/tests/resume.e2e.ts b/examples/coding-agent/tests/resume.e2e.ts index 70382b4beb..48e135ad92 100644 --- a/examples/coding-agent/tests/resume.e2e.ts +++ b/examples/coding-agent/tests/resume.e2e.ts @@ -4,7 +4,6 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' import type { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' -import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' @@ -40,7 +39,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses // log on disk survives. ctx = await codingHarness(process.cwd(), { persona: SYSTEM_PROMPT, persistenceRoot: root }) const first = (await ctx.agents.create({ - agentId: AgentId('resume-1'), sessionId: SESSION_ID, agentOptions: { model: 'deepseek-v4-flash' }, })).agent as ReactLoopAgent @@ -54,7 +52,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses // run 1's exchange as conversation history. ctx = await codingHarness(process.cwd(), { persona: SYSTEM_PROMPT, persistenceRoot: root }) const resumed = (await ctx.agents.resume({ - agentId: AgentId('resume-2'), resumeSessionId: SESSION_ID, agentOptions: { model: 'deepseek-v4-flash' }, })).agent as ReactLoopAgent diff --git a/examples/coding-agent/tests/todo-write.e2e.ts b/examples/coding-agent/tests/todo-write.e2e.ts index 698fbd9e9e..7f3c3c1e4a 100644 --- a/examples/coding-agent/tests/todo-write.e2e.ts +++ b/examples/coding-agent/tests/todo-write.e2e.ts @@ -3,8 +3,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' import { codingHarness, TODO_SYSTEM_PROMPT, waitForIdle } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * A REAL model drives the REAL todo_write tool: verify the WORLD (the session @@ -26,7 +26,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('todo_write: real model records a it('appends a todo/write event with the model-produced task list', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-todo-write-e2e-')) ctx = await codingHarness(workdir, { persona: TODO_SYSTEM_PROMPT }) - const agent = ctx.agentLoop.create(AgentId('e2e-todo'), { model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('e2e-todo'), { model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', text: 'Use the todo_write tool to record a plan of exactly two steps: first ' diff --git a/examples/cordis-agent/tests/cordis-tools.e2e.ts b/examples/cordis-agent/tests/cordis-tools.e2e.ts index 388fcb0058..ffe9a290eb 100644 --- a/examples/cordis-agent/tests/cordis-tools.e2e.ts +++ b/examples/cordis-agent/tests/cordis-tools.e2e.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' -import { AgentId } from '@deepseek-ai/dsh-agent' import { cordisHarness, waitForIdle } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * With-key smoke for the self-referential cordis tools: a REAL model drives @@ -38,7 +38,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif it('mounts a status listener whose tagged output actually fires, then unmounts it', async () => { ctx = await cordisHarness() const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - const agent = ctx.agentLoop.create(AgentId('cordis-e2e-listener'), { model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('cordis-e2e-listener'), { model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', @@ -66,7 +66,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif it('builds itself a reverse_text tool and actually calls it', async () => { ctx = await cordisHarness() - const agent = ctx.agentLoop.create(AgentId('cordis-e2e-selftool'), { model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('cordis-e2e-selftool'), { model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', @@ -114,7 +114,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif it('composes two mounts through provide/inject, and unmounting the provider parks the consumer', async () => { ctx = await cordisHarness() - const agent = ctx.agentLoop.create(AgentId('cordis-e2e-compose'), { model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('cordis-e2e-compose'), { model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index a22e86bd99..3ab3ae6628 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry 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' @@ -74,7 +75,7 @@ describe('bash tool through the agent loop', () => { textResponse('The command printed integration-ok.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('it-fg'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('it-fg'), { model: 'mock' }) agent.send([{ type: 'text', text: 'run echo integration-ok' }]) await waitForIdle(ctx, agent) @@ -106,7 +107,7 @@ describe('bash tool through the agent loop', () => { textResponse('It failed with code 9.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('it-exit'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('it-exit'), { model: 'mock' }) agent.send([{ type: 'text', text: 'run exit 9' }]) await waitForIdle(ctx, agent) @@ -128,7 +129,7 @@ describe('bash tool through the agent loop', () => { let taskId = '' const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('it-bg'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('it-bg'), { model: 'mock' }) // Capture the generated id so the deterministic fixture is checked against // the real executor instead of silently assuming it. diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 12b4a53958..c68c445f0a 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -57,7 +57,7 @@ async function setup() { const fakeAgentDisposers = new Map Promise | void)[]>() function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void): Agent { // The registry KEY (agent.id) is deliberately DIFFERENT from the session - // token (session.header.id) — a config agent has `agentId !== sessionId`. The + // token (session.header.id), which is also the agent's durable id. The // owner token IS the session id, so the notice path must find the agent by // `session.header.id`, NOT the registry key. Using distinct values here makes // the test fail if a regression matched on the wrong field (a same-value fake diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index bb77e44a9c..52a2ecd134 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -3,11 +3,12 @@ import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import { isToolPairingBalanced } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' @@ -119,7 +120,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () it('the head checkpoint the loop lands is a balanced cut on both sides', async () => { const { ctx } = await harness(8) try { - const agent = ctx.agentLoop.create(AgentId('repro'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('repro'), { model: 'mock' }) agent.send([{ type: 'text', text: 'do a long multi-step task' }]) await waitForIdle(ctx, agent) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 3d6ade301f..5721bd97d9 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -56,7 +56,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'agentLoop', summary: 'Concrete ReactLoopAgent factory and driver service.', methods: [ - 'create(id: AgentId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent', + 'create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent', 'async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise', 'async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise', ], @@ -71,7 +71,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 'register(agent: Agent): () => void', 'enter(agent: Agent): () => void', 'announce(agent: Agent): void', - 'get(id: AgentId): Agent | undefined', + 'get(id: SessionId): Agent | undefined', 'list(): Agent[]', ], }, @@ -488,7 +488,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ export const TYPE_API: readonly TypeApiEntry[] = [ { name: 'Agent', - declaration: 'export interface Agent {\n readonly id: AgentId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: SendOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n}', + declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: SendOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n}', }, { name: 'AgentFactory', @@ -498,10 +498,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentHandle', declaration: 'export interface AgentHandle {\n agent: Agent;\n dispose(): Promise;\n}', }, - { - name: 'AgentId', - declaration: 'export type AgentId = Branded<\'AgentId\'>;', - }, { name: 'AgentOptions', declaration: 'export interface AgentOptions {\n model?: string;\n}', @@ -644,7 +640,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CreateAgentOptions', - declaration: 'export interface CreateAgentOptions {\n readonly agentId: AgentId;\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', + declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', }, { name: 'CreateSessionOptions', @@ -756,7 +752,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ResumeAgentOptions', - declaration: 'export interface ResumeAgentOptions {\n readonly agentId: AgentId;\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', + declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', }, { name: 'SandboxEnforcement', @@ -868,7 +864,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentRun', - declaration: 'export interface SubagentRun {\n readonly id: AgentId;\n readonly result: Promise;\n dispose(): Promise;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): Promise;\n}', + declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly result: Promise;\n dispose(): Promise;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): Promise;\n}', }, { name: 'SubagentStartRequest', diff --git a/packages/cordis/tool-cordis/tests/integration.spec.ts b/packages/cordis/tool-cordis/tests/integration.spec.ts index 94331df7c0..0d4c6c5c18 100644 --- a/packages/cordis/tool-cordis/tests/integration.spec.ts +++ b/packages/cordis/tool-cordis/tests/integration.spec.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import * as ToolCordis from '../src/index.ts' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -51,7 +52,7 @@ describe('cordis tools through the agent loop', () => { textResponse('Done.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('it-cordis'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('it-cordis'), { model: 'mock' }) agent.send([{ type: 'text', text: 'give yourself reverse_text, use it, clean up' }]) await waitForIdle(ctx, agent) diff --git a/packages/core/agent-core/README.md b/packages/core/agent-core/README.md index 33cd0faddd..accbf66a7c 100644 --- a/packages/core/agent-core/README.md +++ b/packages/core/agent-core/README.md @@ -43,7 +43,7 @@ import type { Config } from '@deepseek-ai/dsh-agent-core' // so validation and defaulting can never drift from the owners. ``` -The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; and `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure. +The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates one under the `main` config label; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; and `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure. ## Why a code bundle, not a shared YAML include diff --git a/packages/core/agent-core/tests/agent-core.spec.ts b/packages/core/agent-core/tests/agent-core.spec.ts index 855bb28d49..058191c88f 100644 --- a/packages/core/agent-core/tests/agent-core.spec.ts +++ b/packages/core/agent-core/tests/agent-core.spec.ts @@ -6,8 +6,10 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import * as agentCore from '../src/index.ts' -import { AgentId, agentEvents, type Agent } from '@deepseek-ai/dsh-agent' +import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' + import type { Message } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' async function composePrefix(ctx: Context, cwd: string): Promise { const agent = { session: { header: { cwd } } } as unknown as Agent @@ -102,16 +104,18 @@ describe('dsh-agent-core bundle', () => { it('defaults the agents list to empty (no pre-created agents)', async () => { const ctx = await mount() - expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined() + expect(ctx.get('agents')?.get(SessionId('main'))).toBeUndefined() await ctx.fiber.dispose() }) it('forwards a pre-created agent to the loop and the persona to system-prompt', async () => { const ctx = await mount({ - agents: [{ id: AgentId('main'), model: 'mock' }], + agents: [{ id: 'main', model: 'mock' }], persona: 'You are main.', }) - expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined() + const agent = ctx.get('agents')?.list()[0] + expect(agent?.id).toBe(agent?.session.id) + expect(agent?.id).toMatch(/^main-session-/) const assembly = await ctx.get('systemPrompt')!.assemble() expect(assembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You are main.') await ctx.fiber.dispose() diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 6832800d2e..f40577bb95 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -12,14 +12,14 @@ Creation and resume are one rollback-covered transaction: construct a private se The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly, while the factory keeps its own dependency context for `sessions`/`llm`/`tools`/`systemPrompt`; this lets a caller inject only `agents` without shrinking the new agent's service surface. Caller unload, handle disposal, or provider unload converge on one memoized quiescence boundary. Provider shutdown waits both resource teardown and the public create/resume wrapper that observed deactivation, so no continuation can publish after dependencies disappear. -IDs are caller-chosen and assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same agent or session id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain (including outstanding idle-injection flushes) → detach agent → detach session → unwind scope; IDs become reusable at detach even if private scope cleanup is still finishing. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, per-step assembly goes through `assembleContextFor(agent)`, and turn-end durability checkpoints go through `ctx.sessions.flush(session)`. +Each agent and its session share one caller-chosen `SessionId`, assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain (including outstanding idle-injection flushes) → detach agent → detach session → unwind scope; the id becomes reusable at detach even if private scope cleanup is still finishing. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, per-step assembly goes through `assembleContextFor(agent)`, and turn-end durability checkpoints go through `ctx.sessions.flush(session)`. -- `ctx.agentLoop.create(id: string, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — synchronous no-setup create, used directly by programs and by `cordis.yml`-configured agents. It creates a fresh per-run session id `${id}-session-` with optional metadata; the uuid avoids colliding with a prior durable log. Each call is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber. +- `ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — synchronous no-setup create under the exact shared agent/session id, disposed with the calling fiber. Declarative config treats `agents[].id` as a stable label and mints `${label}-session-` before calling this boundary; `resumeSessionId` instead loads and registers the exact persisted id. This keeps fresh restarts collision-free without retaining a second live routing identity. `AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface): -- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise` — programmatic create on a caller-supplied `sessionId`, NOT `${id}-session`. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown. -- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions?, setup?, signal? }): Promise` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`. +- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise` — programmatic create under the caller-supplied shared id. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown. +- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), register the agent under that same id, reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. Turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`. The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle). For a programmatic agent, the handle holder is the only consumer-facing teardown capability; AgentLoop provider unload is the independent structural teardown edge, not another handle exposed to application code. @@ -32,7 +32,7 @@ The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loo ```ts interface Config { agents: Array<{ - id: string // required + id: string // required stable label; prefixes fresh combined ids model?: string resumeSessionId?: string // load this persisted session instead of creating one cwd?: string // optional workspace cwd for the fresh session diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index ff66f942be..d54baab597 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -8,11 +8,11 @@ import type { Context } from 'cordis' import { agentEvents } from '@deepseek-ai/dsh-agent' -import type { AgentId, AgentOptions, AgentStatus, SendOptions } from '@deepseek-ai/dsh-agent' +import type { AgentOptions, AgentStatus, SendOptions } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { deepFreeze } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import { snapshotJsonValue, type Session } from '@deepseek-ai/dsh-session' +import { snapshotJsonValue, type Session, type SessionId } from '@deepseek-ai/dsh-session' import { Inbox, type InboxMessage } from './inbox.ts' import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts' @@ -59,7 +59,7 @@ export interface PreparedReactLoopAgent { * @returns the agent and closures bound only to that exact instance. */ export function prepareReactLoopAgent( - ctx: Context, id: AgentId, options: AgentOptions, session: Session, + ctx: Context, id: SessionId, options: AgentOptions, session: Session, ): PreparedReactLoopAgent { if (claimedDriverSessions.has(session)) { throw new Error(`session "${session.id}" already has a concrete agent driver`) @@ -163,7 +163,7 @@ export class ReactLoopAgent implements Agent { constructor( private loopCtx: Context, - public readonly id: AgentId, + public readonly id: SessionId, public readonly options: AgentOptions, public readonly session: Session, ) { diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 4ad4779176..33276c01f8 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -14,7 +14,6 @@ import { agentEvents } from '@deepseek-ai/dsh-agent' import type { AgentFactory, AgentHandle, - AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions, @@ -68,7 +67,7 @@ class FactoryOwnership { } /** Build the public cancellation error while preserving a caller-supplied cause. */ -function signalAbortError(id: AgentId, signal: AbortSignal): Error { +function signalAbortError(id: SessionId, signal: AbortSignal): Error { if (signal.reason instanceof Error) return signal.reason return new Error(`agent "${id}" creation aborted`, { cause: signal.reason }) } @@ -108,7 +107,7 @@ class AgentCreationTransaction { private readonly loopCtx: Context, private readonly ownerCtx: Context, private readonly ownership: FactoryOwnership, - readonly id: AgentId, + readonly id: SessionId, signal?: AbortSignal, ) { ownerCtx.fiber.assertActive() @@ -325,8 +324,8 @@ declare module 'cordis' { export interface Config { /** Agents created or resumed at plugin startup. */ agents: (AgentOptions & { - /** Registry identity for the live agent. */ - id: AgentId + /** Stable config label used in logs and as the fresh combined-id prefix. */ + id: string /** Optional workspace for a fresh session. */ cwd?: string /** Persisted session to resume instead of creating a fresh session. */ @@ -363,13 +362,13 @@ export class AgentLoop extends Service implements AgentFactory { for (const { id, cwd, resumeSessionId, ...options } of config.agents) { if (resumeSessionId === undefined || resumeSessionId === '') { - this.create(id, options, cwd === undefined ? {} : { cwd }) + const sessionId = SessionId(`${id}-session-${randomUUID()}`) + this.create(sessionId, options, cwd === undefined ? {} : { cwd }) continue } ctx.effect(() => { const fiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => { void this.resumeWith(ctx, childCtx.sessionPersistence, { - agentId: id, resumeSessionId, agentOptions: options, }).catch((error: unknown) => { @@ -382,19 +381,19 @@ export class AgentLoop extends Service implements AgentFactory { } /** - * Create an agent on a fresh per-run session, owned by the accessing fiber. - * Constructor-driven config calls use the loop fiber itself. - * @param id - agent registry id. + * Create an agent and session under one caller-supplied identity, owned by + * the accessing fiber. Constructor-driven config calls mint a fresh combined + * id before entering this boundary. + * @param id - shared agent/session identity. * @param options - concrete loop options. * @param meta - optional fresh-session workspace metadata. * @returns the published running agent. */ - create(id: AgentId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent { + create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent { const loopCtx = this.runtime.ctx const transaction = new AgentCreationTransaction(loopCtx, this.ctx, this.ownership, id) try { - const sessionId = SessionId(`${id}-session-${randomUUID()}`) - const session = loopCtx.sessions.prepare(sessionId, { meta }) + const session = loopCtx.sessions.prepare(id, { meta }) const agent = transaction.prepare(options, session) transaction.publish('startup') return agent @@ -417,7 +416,7 @@ export class AgentLoop extends Service implements AgentFactory { this.runtime.ctx, ownerCtx, this.ownership, - options.agentId, + options.sessionId, options.signal, ) try { @@ -461,7 +460,7 @@ export class AgentLoop extends Service implements AgentFactory { this.runtime.ctx, ownerCtx, this.ownership, - options.agentId, + options.resumeSessionId, options.signal, ) try { diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 7f65bbc5f3..56d6489fbe 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -53,10 +52,10 @@ describe('ReactLoopAgent', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('exclusive-driver')) - const prepared = prepareReactLoopAgent(ctx, AgentId('first-driver'), { model: 'mock' }, session) + const prepared = prepareReactLoopAgent(ctx, SessionId('first-driver'), { model: 'mock' }, session) expect(() => prepared.agent.ctx).toThrow('context is not bound') - expect(() => prepareReactLoopAgent(ctx, AgentId('second-driver'), { model: 'mock' }, session)) + expect(() => prepareReactLoopAgent(ctx, SessionId('second-driver'), { model: 'mock' }, session)) .toThrow('already has a concrete agent driver') await prepared.dispose() @@ -66,11 +65,11 @@ describe('ReactLoopAgent', () => { it('borrows caller options and binds its scoped context exactly once', async () => { const ctx = await harness(new MockAdapter([textResponse('unused')])) const options = { model: 'mock' } - const agent = ctx.agentLoop.create(AgentId('owned-bindings'), options) + const agent = ctx.agentLoop.create(SessionId('owned-bindings'), options) expect(agent.options).toBe(options) expect(agent.id).toBe('owned-bindings') - expect(agent.session.id).toMatch(/^owned-bindings-session-/) + expect(agent.session.id).toBe(agent.id) expect(() => { bindReactLoopAgentContext(agent, new Context()) }).toThrow(/context is already bound/) await ctx.fiber.dispose() @@ -81,7 +80,7 @@ describe('ReactLoopAgent', () => { const ctx = await harness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -96,7 +95,7 @@ describe('ReactLoopAgent', () => { const ctx = await harness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -111,7 +110,7 @@ describe('ReactLoopAgent', () => { const ctx = await harness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -124,7 +123,7 @@ describe('ReactLoopAgent', () => { it('inject() decides enclosure from the LOG (open turn), not agent status', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('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 @@ -150,7 +149,7 @@ describe('ReactLoopAgent', () => { // A persistence-like listener whose flush rejects. ctx.on('session/flush', () => { throw new Error('disk gone') }) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('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. @@ -163,7 +162,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(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let flushes = 0 ctx.on('session/flush', () => { flushes += 1 }) @@ -183,7 +182,7 @@ describe('ReactLoopAgent', () => { it('idle inject() still checkpoints when a listener throws on the synthetic turn/end', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let flushes = 0 ctx.on('session/flush', () => { flushes += 1 }) // Session contains a throwing post-commit turn/end observer. The accepted @@ -206,7 +205,7 @@ describe('ReactLoopAgent', () => { // A non-Error rejection exercises the String() normalization branch. ctx.on('session/flush', () => { throw 'disk gone' }) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('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 })) @@ -225,7 +224,7 @@ describe('ReactLoopAgent', () => { it('idle inject() with a non-serializable source opens no turn (nothing to close)', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('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. @@ -240,7 +239,7 @@ describe('ReactLoopAgent', () => { it('steer() when idle falls through to send() and starts a turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // steer while idle delegates to send agent.steer([{ type: 'text', text: 'steer idle' }], { source: { kind: 'plugin', plugin: 'test' } }) @@ -258,7 +257,7 @@ describe('ReactLoopAgent', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('test')) - const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session) + const prepared = prepareReactLoopAgent(ctx, SessionId('bare'), { model: 'mock' }, session) const { agent } = prepared // Start the loop to get the disposer; the agent waits for messages @@ -280,7 +279,7 @@ describe('ReactLoopAgent', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('pre-start-dispose')) - const prepared = prepareReactLoopAgent(ctx, AgentId('pre-start-dispose'), { model: 'mock' }, session) + const prepared = prepareReactLoopAgent(ctx, SessionId('pre-start-dispose'), { model: 'mock' }, session) await prepared.dispose() expect(prepared.agent.status).toBe('disposed') @@ -294,7 +293,7 @@ describe('ReactLoopAgent', () => { it('setting the same status does not emit agent/status again', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const statuses: string[] = [] ctx.on('agent/status', (subject, status) => { @@ -313,7 +312,7 @@ describe('ReactLoopAgent', () => { it('whenIdle() resolves immediately when the agent is not running', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // Fresh agent is idle — whenIdle() takes the not-running fast path and // resolves without subscribing. await must not hang. @@ -324,7 +323,7 @@ describe('ReactLoopAgent', () => { it('whenIdle() waits for queued work that has not flipped status yet', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'queued') let settled = false @@ -342,8 +341,8 @@ describe('ReactLoopAgent', () => { it('whenIdle() awaits the running→idle transition, ignoring other subjects/running events', async () => { const adapter = new MockAdapter([textResponse('ok'), textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - const other = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) + const other = ctx.agentLoop.create(SessionId('a2'), { model: 'mock' }) // Drive `agent` into `running`, then await whenIdle() — it subscribes to // agent/status and resolves on the first transition out of running. @@ -379,7 +378,7 @@ describe('ReactLoopAgent', () => { const adapter = new MockAdapter(['hang']) ctx.llm.registerAdapter(['mock'], adapter) const session = ctx.sessions.create(SessionId('bare')) - const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session) + const prepared = prepareReactLoopAgent(ctx, SessionId('bare'), { model: 'mock' }, session) const { agent } = prepared prepared.markPublished() const dispose = prepared.startDriver() @@ -404,7 +403,7 @@ describe('ReactLoopAgent', () => { const ctx = await harness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -425,7 +424,7 @@ describe('ReactLoopAgent', () => { const ctx = await harness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -446,7 +445,7 @@ describe('ReactLoopAgent', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.on('agent/status', (_subject, status) => { if (status === 'running') throw new Error('bad running listener') }) @@ -464,7 +463,7 @@ describe('ReactLoopAgent', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.on('agent/status', (_subject, status) => { if (status === 'idle') throw new Error('bad idle listener') }) diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 56376b69a7..82b1f6c58c 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -16,7 +16,8 @@ import LlmService, { type Message } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -57,7 +58,7 @@ describe('Agent.cancel()', () => { it('cancel() on an idle agent with nothing queued is a no-op; the next prompt runs (F2 leak guard)', async () => { const adapter = new MockAdapter([textResponse('reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('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. @@ -74,7 +75,7 @@ describe('Agent.cancel()', () => { it('pre-step cancel drops the about-to-start turn (no turn is opened)', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('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. @@ -93,7 +94,7 @@ describe('Agent.cancel()', () => { it('a whenIdle() waiter registered BEFORE a pre-step cancel resolves (F1 hang guard)', async () => { const adapter = new MockAdapter([textResponse('x')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('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. @@ -114,7 +115,7 @@ describe('Agent.cancel()', () => { it('cancel() mid-step aborts the in-flight model call; the turn ends aborted', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -131,7 +132,7 @@ describe('Agent.cancel()', () => { it('cancel() with no reason defaults to "cancelled" when aborting an in-flight step', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -147,7 +148,7 @@ describe('Agent.cancel()', () => { it('a prompt sent AFTER a cancelled turn settles runs normally (marker reset)', async () => { const adapter = new MockAdapter(['hang', textResponse('second reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // First turn hangs; cancel it mid-step. send(agent, 'first') @@ -169,7 +170,7 @@ describe('Agent.cancel()', () => { it('cancel from inside the agent/session-prefix waterfall drops the step (prefix-composition window)', async () => { const adapter = new MockAdapter([textResponse('should not stream')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // Prefix composition runs before the pre-step seam on the instance's first // step; a cancel landing inside it must drop the about-to-start step @@ -203,7 +204,6 @@ describe('Agent.cancel()', () => { ctx.llm.registerAdapter(['mock'], adapter) const handle = await ctx.agents.create({ - agentId: AgentId('a-dispose-prefix'), sessionId: SessionId('dispose-prefix-session'), agentOptions: { model: 'mock' }, }) @@ -232,7 +232,7 @@ describe('Agent.cancel()', () => { it('a cancel-interrupted prefix composition is discarded: the next send recomposes and ships the fresh prefix (stale-cache guard)', async () => { const adapter = new MockAdapter([textResponse('reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // The first composition is interrupted mid-waterfall and — like an // abort-aware listener bailing on a firing signal — contributes nothing. @@ -266,7 +266,7 @@ describe('Agent.cancel()', () => { it('cancel from a synchronous turn/start session-event listener drops the step (step-start window)', async () => { const adapter = new MockAdapter([textResponse('should not stream')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // A turn/start listener fires right after turn/start is appended, BEFORE any // AbortController is installed for the step. Cancelling there must still drop @@ -295,7 +295,7 @@ describe('Agent.cancel()', () => { it('cancel from a synchronous step/start session-event listener drops the step (post-step-start window)', async () => { const adapter = new MockAdapter([textResponse('should not stream')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // A step/start session-event listener fires AFTER step/start is appended // (and after the pre-step seam), so cancelling there lands in the SECOND @@ -334,7 +334,6 @@ describe('Agent.cancel()', () => { ctx.llm.registerAdapter(['mock'], adapter) const handle = await ctx.agents.create({ - agentId: AgentId('a-dispose-step-start'), sessionId: SessionId('dispose-step-start-session'), agentOptions: { model: 'mock' }, }) @@ -366,7 +365,7 @@ describe('Agent.cancel()', () => { // `aborted` and run NO second step. const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let steps = 0 const reasons: TurnEndReason[] = [] @@ -398,7 +397,7 @@ describe('Agent.cancel()', () => { it('cancel from a synchronous agent/status(running) listener drops the turn (window 2)', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('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 @@ -428,7 +427,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(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let replaced = false const dispose = ctx.on('agent/status', (subject, status) => { @@ -458,7 +457,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(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('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) @@ -478,7 +477,7 @@ describe('Agent.cancel()', () => { it("cancel clears the turn's steering — it is not re-enqueued as a fresh turn", async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 07d6cd9e9b..134ea68c32 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -7,7 +7,8 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -32,7 +33,7 @@ describe('config-driven session id', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) const loopFiber = await ctx.plugin(AgentLoop, { - agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('deferred') }], + agents: [{ id: 'main', model: 'mock', resumeSessionId: SessionId('deferred') }], }) const resumeEffect = loopFiber.getEffects().find(effect => effect.label === 'agentLoop.resume(main)') @@ -53,11 +54,13 @@ describe('config-driven session id', () => { await ctx1.plugin(SystemPrompt) await ctx1.plugin(ToolRegistry) await ctx1.plugin(AgentRegistry) - await ctx1.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock' }] }) + await ctx1.plugin(AgentLoop, { agents: [{ id: 'cfg', model: 'mock' }] }) await ctx1.plugin(SessionPersistenceJsonl, { root }) ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg')])) - const a1 = ctx1.agents.get(AgentId('cfg')) as ReactLoopAgent + const a1 = ctx1.agents.list()[0] as ReactLoopAgent + expect(a1.id).toBe(a1.session.id) expect(a1.session.id).toMatch(idPattern) + expect(ctx1.agents.get(SessionId('cfg'))).toBeUndefined() a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -70,10 +73,11 @@ describe('config-driven session id', () => { await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRegistry) await ctx2.plugin(AgentRegistry) - await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock' }] }) + await ctx2.plugin(AgentLoop, { agents: [{ id: 'cfg', model: 'mock' }] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg2')])) - const a2 = ctx2.agents.get(AgentId('cfg')) as ReactLoopAgent + const a2 = ctx2.agents.list()[0] as ReactLoopAgent + expect(a2.id).toBe(a2.session.id) expect(a2.session.id).toMatch(idPattern) expect(a2.session.id).not.toBe(a1.session.id) a2.send([{ type: 'text', text: 'q2' }], { source: { kind: 'user' } }) @@ -96,7 +100,7 @@ describe('config-driven session id', () => { await ctx1.plugin(AgentLoop, { agents: [] }) await ctx1.plugin(SessionPersistenceJsonl, { root }) ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')])) - const a1 = (await ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sticky-1') })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('sticky-1') })).agent as ReactLoopAgent a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -110,7 +114,7 @@ describe('config-driven session id', () => { await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRegistry) await ctx2.plugin(AgentRegistry) - await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('sticky-1') }] }) + await ctx2.plugin(AgentLoop, { agents: [{ id: 'main', model: 'mock', resumeSessionId: SessionId('sticky-1') }] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')])) @@ -118,11 +122,12 @@ 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(AgentId('main')) as ReactLoopAgent | undefined + resumed = ctx2.agents.get(SessionId('sticky-1')) as ReactLoopAgent | undefined } expect(resumed).toBeDefined() // The live session id IS the resumed id (NOT a fresh ${id}-session-), // and the prior turn's user message is in the derived history. + expect(resumed!.id).toBe(SessionId('sticky-1')) expect(resumed!.session.id).toBe('sticky-1') const derived = resumed!.session.deriveMessages() expect(JSON.stringify(derived)).toContain('remember me') @@ -138,16 +143,16 @@ describe('config-driven session id', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('does-not-exist') }] }) + await ctx.plugin(AgentLoop, { agents: [{ id: 'main', model: 'mock', resumeSessionId: SessionId('does-not-exist') }] }) const warn = vi.spyOn((ctx.agentLoop as unknown as { ctx: { logger: { warn: (...a: unknown[]) => void } } }).ctx.logger, 'warn') .mockImplementation(() => undefined) await ctx.plugin(SessionPersistenceJsonl, { root }) ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('x')])) // The deferred resume fails (no such session on disk). It must be contained: - // a warning is logged, no 'main' agent is registered, and the app stays up. + // a warning is logged, no agent is registered, and the app stays up. await new Promise(r => setTimeout(r, 200)) - expect(ctx.agents.get(AgentId('main'))).toBeUndefined() + expect(ctx.agents.list()).toEqual([]) expect(warn).toHaveBeenCalledWith(expect.stringContaining('config-driven resume of "does-not-exist" failed')) warn.mockRestore() await ctx.fiber.dispose() diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 23e5670f85..4e82e398f1 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService, { CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm' -import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -40,7 +41,7 @@ describe('inbox acceptance', () => { it('rejects non-serializable content or source synchronously before notification or enqueue', async () => { const adapter = new MockAdapter([textResponse('turn 1')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let queued = 0 ctx.on('agent/queued', () => { queued += 1 }) @@ -80,7 +81,7 @@ describe('tool JSON parse', () => { return [{ type: 'text', text: typeof args === 'string' ? `raw: ${args}` : JSON.stringify(args) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'use tool') await waitForIdle(ctx, agent) @@ -113,7 +114,7 @@ describe('tool JSON parse', () => { return [{ type: 'text', text: 'ran with empty args' }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'use tool') await waitForIdle(ctx, agent) @@ -126,7 +127,7 @@ describe('toError normalization', () => { it('normalizes non-Error throws from pre-commit dispatch validation via the runLoop backstop', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let threwOnce = false ctx.on('internal/dispatch', (_mode, name, args) => { @@ -152,7 +153,7 @@ describe('toError normalization', () => { it('normalizes non-Error throws from agent/request waterfall via inline toError in runStep catch', async () => { const adapter = new MockAdapter([textResponse('irrelevant')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let threwOnce = false ctx.on('agent/request', async (_agent, _turn, _step, _options, _next) => { @@ -180,7 +181,7 @@ describe('coded error data emission', () => { it('errorData includes code when a coded error (LlmError) is thrown from a plugin', async () => { const adapter = new MockAdapter([textResponse('turn 1')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let threwOnce = false ctx.on('agent/request', async (_agent, _turn, _step, _options, next) => { @@ -214,7 +215,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(AgentId('scoped'), { model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -242,7 +243,7 @@ describe('structured tool error propagation (the runtime-validation RFC, part 2) textResponse('done'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.tools.register(defineTool({ name: 'boom', description: 'always fails', diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 1ec48e553f..8c95be0d82 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -1,15 +1,11 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService, { CallId, type Message } from '@deepseek-ai/dsh-llm' -import SessionStore, { type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { - AgentId, - type ContinuationDecision, - type PromptDecision, - type SessionStartSource, -} from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type ContinuationDecision, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent' + import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -57,7 +53,7 @@ describe('agent/prompt-submit', () => { it('allow (default via next) records the user/message unchanged', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const seen: string[] = [] ctx.on('agent/prompt-submit', async (_agent, content, _source, next) => { @@ -76,7 +72,7 @@ describe('agent/prompt-submit', () => { it('allow with content REWRITES the prompt before it is recorded', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.on('agent/prompt-submit', async (): Promise => ({ kind: 'allow', content: [{ type: 'text', text: 'REWRITTEN' }] })) @@ -94,7 +90,7 @@ describe('agent/prompt-submit', () => { it('allow with additionalContext injects a separate context/message into the turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.on('agent/prompt-submit', async (): Promise => ({ @@ -127,7 +123,7 @@ describe('agent/prompt-submit', () => { // elsewhere; this asserts they see each other's effects on the same turn). const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.on('agent/prompt-submit', async (): Promise => ({ @@ -157,7 +153,7 @@ describe('agent/prompt-submit', () => { it('block drops the (only) prompt → zero-step turn ends rejected, model never called', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.on('agent/prompt-submit', async (): Promise => ({ kind: 'block', reason: 'blocked by policy' })) @@ -194,7 +190,7 @@ describe('agent/prompt-submit', () => { // vetoed prompt and its reason would vanish from the log entirely. const adapter = new MockAdapter([textResponse('ran once')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.on('agent/prompt-submit', async (_agent, content, _source, next): Promise => { const text = content.map(b => (b.type === 'text' ? b.text : '')).join('') @@ -230,7 +226,7 @@ describe('agent/prompt-submit', () => { it('a throwing prompt-submit listener ends the turn balanced (error), loop survives', async () => { const adapter = new MockAdapter([textResponse('after')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let threw = false ctx.on('agent/prompt-submit', async () => { @@ -263,7 +259,7 @@ describe('agent/session-start', () => { const sources: SessionStartSource[] = [] ctx.on('agent/session-start', (_agent, source) => void sources.push(source)) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // fires synchronously at create, before any turn expect(sources).toEqual(['startup']) expect(events(agent).some(e => e.type === 'turn/start')).toBe(false) @@ -282,7 +278,7 @@ describe('agent/session-start', () => { agent.inject([{ type: 'text', text: 'session preamble' }], { source: { kind: 'plugin', plugin: 'test' } }) }) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -300,8 +296,8 @@ describe('agent/session-start', () => { ctx.on('agent/session-start', () => { throw new Error('session-start hook broke') }) // create must not throw — the listener error is contained/logged - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - expect(agent.id).toBe(AgentId('a1')) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) + expect(agent.id).toBe(SessionId('a1')) // and the agent still runs send(agent, 'go') @@ -314,8 +310,8 @@ describe('agent/session-prefix', () => { it('dispatches to global and matching agent-scope listeners only', async () => { const adapter = new MockAdapter([textResponse('a done'), textResponse('b done')]) const ctx = await harness(adapter) - const agentA = ctx.agentLoop.create(AgentId('prefix-a'), { model: 'mock' }) - const agentB = ctx.agentLoop.create(AgentId('prefix-b'), { model: 'mock' }) + const agentA = ctx.agentLoop.create(SessionId('prefix-a'), { model: 'mock' }) + const agentB = ctx.agentLoop.create(SessionId('prefix-b'), { model: 'mock' }) const seen: string[] = [] ctx.on('agent/session-prefix', async (agent, _prefix, _signal, next) => { seen.push(`global:${agent.id}`) @@ -352,7 +348,7 @@ describe('agent/session-prefix', () => { name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const reminder: Message = { role: 'user', content: [{ type: 'text', text: 'catalog' }] } let composed = 0 @@ -385,7 +381,7 @@ describe('agent/session-prefix', () => { it('composes before the first pre-step and hands the prefix to the seam (pressure gates see the real value)', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const reminder: Message = { role: 'user', content: [{ type: 'text', text: 'opener' }] } const order: string[] = [] @@ -412,7 +408,7 @@ describe('agent/session-prefix', () => { it('the canonical prepend pattern composes contributions in registration order', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // Both listeners use the canonical `[mine, ...await next()]` prepend: the // waterfall unwinds innermost-first (the second listener's array is built @@ -434,7 +430,7 @@ describe('agent/session-prefix', () => { it('with no contributions the header omits messagePrefix and the request is the bare derivation', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // A listener that delegates without contributing — the canonical no-op. ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => next()) @@ -450,7 +446,7 @@ describe('agent/session-prefix', () => { it('the frozen seed rejects in-place mutation — a contribution is a returned extension', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let mutationError: unknown ctx.on('agent/session-prefix', async (_agent, prefix, _signal, next): Promise => { @@ -479,7 +475,7 @@ describe('agent/session-prefix', () => { name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const held: Message = { role: 'user', content: [{ type: 'text', text: 'v1' }] } ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise => [...await next(), held]) @@ -500,7 +496,7 @@ describe('agent/turn-continuation (ContinuationDecision)', () => { it('a continue decision with a reason records next-step steering in the same turn', async () => { const adapter = new MockAdapter([textResponse('step 1 no tools'), textResponse('step 2')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let forced = false ctx.on('agent/turn-continuation', async (_agent, _turn, _default, next): Promise => { @@ -533,7 +529,7 @@ describe('agent/turn-continuation (ContinuationDecision)', () => { name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.on('agent/turn-continuation', async (): Promise => ({ action: 'stop' })) @@ -563,7 +559,7 @@ describe('tools/post-execute additionalContext buffering across a multi-call ste name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // Each call attaches additionalContext naming itself. ctx.on('tools/post-execute', async (exec, _result): Promise => @@ -599,7 +595,7 @@ describe('tools/pre-execute gate (native-plugin permission pattern, end-to-end t name: 'danger', description: 'danger', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.on('tools/pre-execute', async (exec, next): Promise => { if (exec.name === 'danger') return { kind: 'deny', reason: 'blocked dangerous tool' } @@ -663,7 +659,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'please echo hi') await waitForIdle(ctx, agent) @@ -686,7 +682,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) await ctx.plugin(NativeGuard) - const agent = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a2'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -705,7 +701,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se await fiber.dispose() // After disposal, a destructive prompt is NOT blocked (the listener is gone). - const agent = ctx.agentLoop.create(AgentId('a3'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a3'), { model: 'mock' }) send(agent, 'run rm -rf /') await waitForIdle(ctx, agent) // the prompt ran (not rejected) — proving the prompt-submit listener was disposed diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 71be5b339e..c4e49e0fa5 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -4,7 +4,8 @@ import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry 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 +45,7 @@ describe('agent loop', () => { it('runs a simple turn: queued message → model → idle, with ordered events', async () => { const adapter = new MockAdapter([textResponse('hello there')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // All boundaries — turn and step — are durable session events on the // session/event feed (no agent/* mirror). Record them in fire order to @@ -92,7 +93,7 @@ describe('agent loop', () => { return [{ type: 'text', text: `echo: ${args.text}` }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'use the tool') await waitForIdle(ctx, agent) @@ -131,7 +132,7 @@ describe('agent loop', () => { return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a.txt', oldText: null, newText: 'x' }] } } }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'use the tool') await waitForIdle(ctx, agent) @@ -155,7 +156,7 @@ describe('agent loop', () => { return [] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'hi') await waitForIdle(ctx, agent) @@ -169,7 +170,6 @@ describe('agent loop', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter, 'Working in {{cwd}}.') const handle = await ctx.agents.create({ - agentId: AgentId('a-cwd'), sessionId: SessionId('s-cwd'), meta: { cwd: '/work/space' }, agentOptions: { model: 'mock' }, @@ -192,7 +192,7 @@ describe('agent loop', () => { const ctx = await harness(adapter, 'In {{cwd}}.') const errors: Error[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'hi') await waitForIdle(ctx, agent) @@ -233,7 +233,7 @@ describe('agent loop', () => { ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => { return { ...config, model: 'mock' } }) - const agent = ctx.agentLoop.create(AgentId('a-late-model'), {}) + const agent = ctx.agentLoop.create(SessionId('a-late-model'), {}) send(agent, 'hi') await waitForIdle(ctx, agent) @@ -259,7 +259,7 @@ describe('agent loop', () => { parameters: {}, execute: () => Promise.resolve({ content: [{ type: 'text' as const, text: 'apparent success' }], meta }), })) - const agent = ctx.agentLoop.create(AgentId('bad-meta-agent'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('bad-meta-agent'), { model: 'mock' }) send(agent, 'use the tool') await waitForIdle(ctx, agent) @@ -288,7 +288,7 @@ describe('agent loop', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) ctx.on('system-prompt/assemble', async () => ({ sections: [], tools: [], variables: {} })) - const agent = ctx.agentLoop.create(AgentId('a-no-system'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-no-system'), { model: 'mock' }) send(agent, 'hi') await waitForIdle(ctx, agent) @@ -300,7 +300,7 @@ describe('agent loop', () => { it('records raw chunks for replay as assistant/chunk session events', async () => { const adapter = new MockAdapter([textResponse('abc')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'hi') await waitForIdle(ctx, agent) @@ -324,7 +324,7 @@ describe('agent loop', () => { ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.tools.register(defineTool({ name: 'slow', description: '', @@ -356,7 +356,7 @@ describe('agent loop', () => { it('steering while idle behaves like send (starts a turn)', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.steer([{ type: 'text', text: 'hello' }]) await waitForIdle(ctx, agent) @@ -366,7 +366,7 @@ describe('agent loop', () => { it('inject() while idle wraps context in a one-shot turn, visible to the next request', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('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 @@ -393,7 +393,7 @@ describe('agent loop', () => { textResponse('done'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('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. @@ -427,7 +427,7 @@ describe('agent loop', () => { textResponse('step 3'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let steps = 0 ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ }) @@ -453,7 +453,7 @@ describe('agent loop', () => { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.on('agent/turn-continuation', async () => ({ action: 'stop' }) as const) @@ -469,7 +469,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(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => { // The seed is frozen — config is not a mutable per-call knob; a switch @@ -502,7 +502,7 @@ describe('agent loop', () => { name: 'echo', description: 'echo', parameters: {}, async execute() { return [{ type: 'text', text: 'echoed' }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const fires: { turn: number; step: number; fullSystemPrompt: string }[] = [] ctx.on('agent/pre-step', (subject, turn, step, fullSystemPrompt) => { @@ -527,7 +527,7 @@ describe('agent loop', () => { // the derived request for that step (derive happens after step/start). const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let injected = false ctx.on('agent/pre-step', (subject) => { @@ -563,7 +563,7 @@ describe('agent loop', () => { // The loop survives and a follow-up prompt still runs. const adapter = new MockAdapter([textResponse('second turn ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let throwOnce = true ctx.on('agent/pre-step', () => { @@ -597,7 +597,7 @@ describe('agent loop', () => { it('cancel() mid-stream ends the turn with reason aborted', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -617,7 +617,7 @@ describe('agent loop', () => { // turn stops by default and ends max-tokens, not completed. const adapter = new MockAdapter([maxTokensResponse('truncat')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -642,7 +642,7 @@ describe('agent loop', () => { textResponse('second half'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let steps = 0 ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ }) @@ -673,7 +673,7 @@ describe('agent loop', () => { // stop. The per-turn reason must be independent — turn 2 ends completed. const adapter = new MockAdapter([maxTokensResponse('cut'), textResponse('clean')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -706,7 +706,7 @@ describe('agent loop', () => { return [{ type: 'text', text: 'should not run' }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -748,7 +748,7 @@ describe('agent loop', () => { parameters: { text: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'should not run' }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -767,7 +767,7 @@ describe('agent loop', () => { // on the normal step path suppresses a pure trace-only empty assistant/message. const adapter = new MockAdapter([[{ type: 'finish', reason: { kind: 'stop' } }]]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -797,7 +797,7 @@ describe('agent loop', () => { expect(message.content).toEqual([{ type: 'text', text: 'partial text' }]) return next() }) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -824,7 +824,7 @@ describe('agent loop', () => { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let threw = false // Post-commit session observers cannot control the loop. The tool call still // drives the second model request, and the turn completes normally. @@ -843,7 +843,7 @@ describe('agent loop', () => { it('chains queued messages into consecutive turns', async () => { const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const turns: number[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) }) @@ -868,7 +868,7 @@ describe('agent loop', () => { it('awaits session/flush at turn end (persistence checkpoint)', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let flushed = 0 let flushedBeforeIdle = false @@ -888,7 +888,7 @@ describe('agent loop', () => { it('errors from the model surface as agent/error and end the turn', async () => { const adapter = new MockAdapter([]) // script exhausted → throws const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const errors: Error[] = [] const reasons: TurnEndReason[] = [] @@ -913,10 +913,10 @@ describe('agent loop', () => { let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) - expect(ctx.agents.get(AgentId('scoped'))).toBe(agent) + expect(ctx.agents.get(SessionId('scoped'))).toBe(agent) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') @@ -925,7 +925,7 @@ describe('agent loop', () => { await agent.done expect(agent.status).toBe('disposed') - expect(ctx.agents.get(AgentId('scoped'))).toBeUndefined() + expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined() expect(() => { send(agent, 'too late') }).toThrow('disposed') }) @@ -938,13 +938,14 @@ describe('agent loop', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { - agents: [{ id: AgentId('config-agent'), model: 'mock' }], + agents: [{ id: 'config-agent', model: 'mock' }], }) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agents.get(AgentId('config-agent'))! as ReactLoopAgent + const agent = ctx.agents.list()[0]! as ReactLoopAgent expect(agent).toBeDefined() - expect(agent.id).toBe('config-agent') + expect(agent.id).toBe(agent.session.id) + expect(agent.id).toMatch(/^config-agent-session-/) expect(agent.options.model).toBe('mock') // the agent is alive: send triggers a turn @@ -961,10 +962,10 @@ describe('agent loop', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { - agents: [{ id: AgentId('config-agent'), model: 'mock', cwd: '/work/project' }], + agents: [{ id: 'config-agent', model: 'mock', cwd: '/work/project' }], }) - const agent = ctx.agents.get(AgentId('config-agent'))! as ReactLoopAgent + const agent = ctx.agents.list()[0]! as ReactLoopAgent expect(agent.session.header.cwd).toBe('/work/project') }) @@ -982,7 +983,7 @@ describe('agent loop', () => { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'run') await waitForIdle(ctx, agent) diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index 1e603a1cc4..b3653d6540 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -14,10 +14,11 @@ import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { LlmAdapter } from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import fc from 'fast-check' @@ -95,7 +96,7 @@ describe('agent loop scheduling properties', () => { async (texts) => { const ctx = await harness() try { - const agent = ctx.agentLoop.create(AgentId('a'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('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 +121,7 @@ describe('agent loop scheduling properties', () => { async (texts) => { const ctx = await harness() try { - const agent = ctx.agentLoop.create(AgentId('a'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a'), { model: 'mock' }) for (const text of texts) { const idle = nextIdle(ctx, agent) agent.send([{ type: 'text', text }]) @@ -145,7 +146,7 @@ describe('agent loop scheduling properties', () => { async (steps) => { const ctx = await harness() try { - const agent = ctx.agentLoop.create(AgentId('a'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a'), { model: 'mock' }) // Capture an idle waiter before EACH send; the last one is guaranteed // to resolve because the final send always triggers (or joins) a turn // that ends idle. Awaiting an already-resolved waiter is a no-op, so a diff --git a/packages/core/agent-loop/tests/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts index 5ccaa7e797..7eddb6be39 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -1,10 +1,11 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' + import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -71,7 +72,7 @@ function waitForIdle(context: Context, agent: Agent): Promise { describe.skipIf(!process.env.DEEPSEEK_API_KEY)('log-derived request cache hits (real API)', () => { it('every request after the first hits the provider prefix cache', async () => { ctx = await loopHarness() - const agent = ctx.agentLoop.create(AgentId('cache-e2e'), { model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('cache-e2e'), { model: 'deepseek-v4-flash' }) // Turn 1: forces a tool call → at least two steps (two model requests). agent.send([{ type: 'text', text: 'Look up the key "deploy-color" with the lookup tool and tell me the value.' }]) diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 7054b683cb..202a8f6795 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -15,7 +15,8 @@ import type { GenerateOptions } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -74,7 +75,7 @@ describe('request stability across the loop', () => { ]) const ctx = await harness(adapter) registerEcho(ctx) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -95,7 +96,7 @@ describe('request stability across the loop', () => { it('a later turn append-extends the previous turn (one conversation, one log)', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -109,7 +110,7 @@ describe('request stability across the loop', () => { it('a compaction replace rewrites the resend, and the log explains it', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -142,7 +143,7 @@ describe('request stability across the loop', () => { it('a real system-prompt change is a full changed-header snapshot; a stable prompt logs nothing', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two'), textResponse('three')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -166,7 +167,7 @@ describe('request stability across the loop', () => { it('an inject() during the agent/request waterfall joins the NEXT request (the step/start boundary)', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let injected = false ctx.on('agent/request', async (_agent, _turn, _step, _config, next) => { @@ -194,7 +195,7 @@ describe('request stability across the loop', () => { it('a mutation attempt on the frozen request content throws into the step (loud, not silent)', async () => { const adapter = new MockAdapter([textResponse('one')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const errors: Error[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) @@ -215,7 +216,7 @@ describe('request stability across the loop', () => { it('a fresh loop instance over a seeded log anchors with a resume snapshot and stays cache-aligned', async () => { const adapter = new MockAdapter([textResponse('one')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('gen1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('gen1'), { model: 'mock' }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -224,7 +225,6 @@ describe('request stability across the loop', () => { const adapter2 = new MockAdapter([textResponse('two')]) const ctx2 = await harness(adapter2) const handle = await ctx2.agents.create({ - agentId: AgentId('gen2'), sessionId: SessionId('gen2-session'), seed: [...agent.session.events], agentOptions: { model: 'mock' }, @@ -244,7 +244,7 @@ describe('request stability across the loop', () => { it('a delegating listener cannot mutate the seed through next() — the fold stays log-true', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.on('agent/request', async (_agent, _turn, _step, _config, next) => { const config = await next() @@ -278,7 +278,7 @@ describe('request stability across the loop', () => { ]) const ctx = await harness(adapter) registerEcho(ctx) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'go') await waitForIdle(ctx, agent) diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 93605008ec..f5809575ab 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -8,7 +8,8 @@ import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry 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' @@ -83,11 +84,10 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { ctx.on('session/created', () => throwUnknown(failure)) await expect(ctx.agents.resume({ - agentId: AgentId('unknown-resume-failure'), resumeSessionId: sessionId, })).rejects.toBe(failure) - expect(ctx.agents.get(AgentId('unknown-resume-failure'))).toBeUndefined() + expect(ctx.agents.get(SessionId('unknown-resume-failure'))).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() await ctx.fiber.dispose() }) @@ -95,27 +95,26 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { it('createAgent uses the caller-supplied sessionId (not ${id}-session)', async () => { const adapter = new MockAdapter([textResponse('hi')]) const { ctx } = await persistentHarness(adapter) - const { agent } = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('custom-session'), meta: { cwd: '/w' } }) + const { agent } = await ctx.agents.create({ sessionId: SessionId('custom-session'), meta: { cwd: '/w' } }) expect(agent.session.id).toBe('custom-session') expect(agent.session.header.cwd).toBe('/w') await ctx.fiber.dispose() }) - it('createAgent rejects a duplicate agent id BEFORE creating the session (no orphan)', async () => { + it('createAgent rejects a duplicate identity without orphaning a session', async () => { const adapter = new MockAdapter([textResponse('hi')]) const { ctx } = await persistentHarness(adapter) - await ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-a') }) - // A second create with the SAME agent id but a fresh session id must reject - // up front — and must NOT leave an orphaned 'sess-b' session behind. - await expect(ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-b') })).rejects.toThrow(/already registered/) - expect(ctx.sessions.get(SessionId('sess-b'))).toBeUndefined() + const sessionId = SessionId('sess-a') + await ctx.agents.create({ sessionId }) + await expect(ctx.agents.create({ sessionId })).rejects.toThrow(/already exists/) + expect(ctx.sessions.list()).toHaveLength(1) await ctx.fiber.dispose() }) it('createAgent works without meta (no cwd)', async () => { const adapter = new MockAdapter([textResponse('hi')]) const { ctx } = await persistentHarness(adapter) - const { agent } = await ctx.agents.create({ agentId: AgentId('a-nometa'), sessionId: SessionId('nometa-session') }) + const { agent } = await ctx.agents.create({ sessionId: SessionId('nometa-session') }) expect(agent.session.id).toBe('nometa-session') expect(agent.session.header.cwd).toBeUndefined() await ctx.fiber.dispose() @@ -125,7 +124,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // Lifecycle 1: create a no-cwd session and run a turn. const adapter1 = new MockAdapter([textResponse('a')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -141,7 +140,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) - const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent expect(a2.session.header.cwd).toBeUndefined() await ctx2.fiber.dispose() }) @@ -152,7 +151,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { const { ctx: ctx1, root } = await persistentHarness(adapter1) const sources1: string[] = [] ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source)) - const a1 = (await ctx1.agents.create({ agentId: AgentId('s'), sessionId: SessionId('start-sess') })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent as ReactLoopAgent expect(sources1).toEqual(['startup']) a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) @@ -171,7 +170,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { ctx2.llm.registerAdapter(['mock'], adapter2) const sources2: string[] = [] ctx2.on('agent/session-start', (_agent, source) => void sources2.push(source)) - await ctx2.agents.resume({ agentId: AgentId('s'), resumeSessionId: SessionId('start-sess') }) + await ctx2.agents.resume({ resumeSessionId: SessionId('start-sess') }) expect(sources2).toEqual(['resume']) await ctx2.fiber.dispose() }) @@ -186,7 +185,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { ctx.on('session/created', (session) => { expect(ctx.sessions.get(session.id)).toBe(session) - expect(ctx.agents.get(AgentId('resumed-atomic'))?.session).toBe(session) + expect(ctx.agents.get(sessionId)?.session).toBe(session) order.push('session/created') }) ctx.on('agent/created', (agent) => { @@ -199,11 +198,10 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { }) const resuming = ctx.agents.resume({ - agentId: AgentId('resumed-atomic'), resumeSessionId: sessionId, agentOptions: { model: 'mock' }, setup: async (agentCtx) => { - expect(agentCtx.agent?.id).toBe(AgentId('resumed-atomic')) + expect(agentCtx.agent?.id).toBe(sessionId) expect(agentCtx.agent?.session.events).toHaveLength(2) agentCtx.on('session/created', () => void order.push('setup-listener:session/created')) agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created')) @@ -215,7 +213,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { }) await setupStarted.promise - expect(ctx.agents.get(AgentId('resumed-atomic'))).toBeUndefined() + expect(ctx.agents.get(sessionId)).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() expect(order).toEqual(['setup:start']) @@ -236,17 +234,15 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { it('successful resume disposal retires its caller-owned transaction effects', async () => { const sessionId = SessionId('resume-retired-effects-s') - const agentId = AgentId('resume-retired-effects') const root = await persistSession(sessionId) const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) const handle = await ctx.agents.resume({ - agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' }, }) const transactionLabels = [ - `agentLoop.owner(${agentId})`, - `agentLoop.lifecycle(${agentId})`, + `agentLoop.owner(${sessionId})`, + `agentLoop.lifecycle(${sessionId})`, ] expect(ctx.fiber.getEffects().map(effect => effect.label)).toEqual(expect.arrayContaining(transactionLabels)) @@ -255,7 +251,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx.fiber.dispose() }) - it('resume setup rejection publishes nothing, unwinds, and releases both identities', async () => { + it('resume setup rejection publishes nothing, unwinds, and releases the identity', async () => { const sessionId = SessionId('resume-setup-reject') const root = await persistSession(sessionId) const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) @@ -265,7 +261,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { ctx.on('agent/session-start', () => void published.push('agent/session-start')) await expect(ctx.agents.resume({ - agentId: AgentId('resume-reject'), resumeSessionId: sessionId, agentOptions: { model: 'mock' }, setup: async () => { @@ -275,10 +270,9 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { })).rejects.toThrow('resume setup failed') expect(published).toEqual([]) - expect(ctx.agents.get(AgentId('resume-reject'))).toBeUndefined() + expect(ctx.agents.get(sessionId)).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() const retry = await ctx.agents.resume({ - agentId: AgentId('resume-reject'), resumeSessionId: sessionId, agentOptions: { model: 'mock' }, }) @@ -299,7 +293,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { let resuming!: ReturnType const owner = await ctx.plugin(Object.assign((inner: Context) => { resuming = inner.agents.resume({ - agentId: AgentId('resume-owner-race'), resumeSessionId: sessionId, agentOptions: { model: 'mock' }, setup: async () => { @@ -313,7 +306,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await owner.dispose() await expect(resuming).rejects.toThrow(/owner disposed during setup/) expect(published).toEqual([]) - expect(ctx.agents.get(AgentId('resume-owner-race'))).toBeUndefined() + expect(ctx.agents.get(sessionId)).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() gate.resolve(undefined) @@ -322,9 +315,8 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx.fiber.dispose() }) - it('owner unload aborts a never-settling persistence load, releases identities, and blocks late publication', async () => { + it('owner unload aborts a never-settling persistence load, releases the identity, and blocks late publication', async () => { const sessionId = SessionId('resume-load-owner-unload') - const agentId = AgentId('resume-load-race') const root = await persistSession(sessionId) const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) const snapshot = await ctx.sessionPersistence.load(sessionId) @@ -348,19 +340,19 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { let resuming!: ReturnType const owner = await ctx.plugin(Object.assign((inner: Context) => { - resuming = inner.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } }) + resuming = inner.agents.resume({ resumeSessionId: sessionId, agentOptions: { model: 'mock' } }) }, { inject: ['agents'] })) await loadStarted.promise const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during setup/) await promptly(owner.dispose()) expect(published).toEqual([]) - expect(ctx.agents.get(agentId)).toBeUndefined() + expect(ctx.agents.get(sessionId)).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() - // owner.dispose() awaited transaction settlement, so the same identities - // can be reused before awaiting the public rejection. - const retry = await promptly(ctx.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } })) + // owner.dispose() awaited transaction settlement, so the identity can be + // reused before awaiting the public rejection. + const retry = await promptly(ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: { model: 'mock' } })) await rejection expect(loads).toBe(2) expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start']) @@ -370,7 +362,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { lateLoad.resolve(structuredClone(snapshot)) await Promise.resolve() await Promise.resolve() - expect(ctx.agents.get(agentId)).toBe(retry.agent) + expect(ctx.agents.get(sessionId)).toBe(retry.agent) expect(ctx.sessions.get(sessionId)).toBe(retry.agent.session) expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start']) @@ -380,7 +372,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { it('AgentLoop unload aborts persistence load and awaits wrapper settlement', async () => { const sessionId = SessionId('resume-load-factory-unload') - const agentId = AgentId('resume-load-factory-race') const root = await persistSession(sessionId) const ctx = new Context() await ctx.plugin(LlmService) @@ -404,14 +395,14 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { ctx.on('session/created', () => void published.push('session/created')) ctx.on('agent/created', () => void published.push('agent/created')) - const resuming = ctx.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } }) + const resuming = ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: { model: 'mock' } }) await loadStarted.promise const rejection = expect(promptly(resuming)).rejects.toThrow(/agent loop is not active/) await promptly(loopFiber.dispose()) await rejection expect(published).toEqual([]) - expect(ctx.agents.get(agentId)).toBeUndefined() + expect(ctx.agents.get(sessionId)).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() lateLoad.resolve(structuredClone(snapshot)) await Promise.resolve() @@ -452,7 +443,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) - const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('forked-sess') })).agent as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('forked-sess') })).agent as ReactLoopAgent expect(a2.session.header.parentSession).toBe('parent-sess') expect(a2.session.header.cwd).toBe('/w') expect(a2.session.header.seedLength).toBe(seed.length) @@ -466,7 +457,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 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent 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' } }) @@ -491,7 +482,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 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent 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' } }) @@ -509,7 +500,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) - const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('inject-sess') })).agent as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('inject-sess') })).agent as ReactLoopAgent const flat = JSON.stringify(a2.session.deriveMessages()) expect(flat).toContain('background task 42 finished') await ctx2.fiber.dispose() @@ -519,7 +510,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // Lifecycle 1: run one full turn, persisting it. const adapter1 = new MockAdapter([textResponse('first answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = (await ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent as ReactLoopAgent a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) const events1 = [...a1.session.events] @@ -539,7 +530,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) - const a2 = (await ctx2.agents.resume({ agentId: AgentId('main'), resumeSessionId: SessionId('sess-resume') })).agent as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('sess-resume') })).agent as ReactLoopAgent // The resumed session carries the prior history… expect(a2.session.id).toBe('sess-resume') expect(a2.session.events.length).toBe(events1.length) @@ -567,7 +558,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: AgentId('m'), resumeSessionId: SessionId('nope') })) + await expect(ctx.agents.resume({ resumeSessionId: SessionId('nope') })) .rejects.toThrow(/session persistence is not configured/) await ctx.fiber.dispose() }) diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index 5898a82ee2..942199e9eb 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -4,7 +4,8 @@ import LlmService, { CallId, ContentBlock, MessageSource, StreamChunk } from '@d import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId, type ContinuationDecision } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type ContinuationDecision } from '@deepseek-ai/dsh-agent' + import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { prepareReactLoopAgent } from '../src/agent.ts' import * as Invariants from '@deepseek-ai/dsh-invariants' @@ -53,7 +54,7 @@ describe('HIGH: session log records what agent/step-result actually produced', ( return [{ type: 'text', text: 'ran' }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // Plugin rewrites the message: replaces the text AND adds a tool call. let rewritten = false @@ -104,7 +105,7 @@ describe('HIGH: abort during tool execution ends the turn', () => { ]) const ctx = await harness(adapter) const executed: string[] = [] - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.tools.register(defineTool({ name: 'aborter', description: '', @@ -148,7 +149,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(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let steeredOnce = false ctx.on('agent/turn-continuation', async (_agent, _turn, _decision, next) => { @@ -188,7 +189,7 @@ describe('HIGH: steering from late extension points is never stranded', () => { textResponse('after goal reminder'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let steeredOnce = false ctx.on('session/event', (subject, event) => { @@ -218,7 +219,7 @@ describe('HIGH: steering from late extension points is never stranded', () => { it('steer() from a turn/end session-event listener becomes a queued message for the next turn', async () => { const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const turns: number[] = [] let steeredOnce = false @@ -244,7 +245,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(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -267,7 +268,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(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let threwOnce = false ctx.on('agent/turn-continuation', async (): Promise => { @@ -295,7 +296,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(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let rejectedOnce = false ctx.on('session/flush', async () => { @@ -325,7 +326,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(AgentId('scoped'), { model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) const statuses: string[] = [] @@ -348,7 +349,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(AgentId('scoped'), { model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) ctx.on('agent/status', (_agent, status) => { @@ -361,7 +362,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(AgentId('scoped'))).toBeUndefined() // unregistered despite the throw + expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined() // unregistered despite the throw }) }) @@ -380,7 +381,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(AgentId('a1'), {}) // no model + const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model const errors: Error[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) @@ -395,7 +396,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(AgentId('a1'), {}) // no model — router plugin decides + const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model — router plugin decides ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => { return { ...config, model: 'mock' } @@ -410,7 +411,7 @@ describe('MEDIUM: misc registry and config fixes', () => { it('agent/queued carries the resolved source; steering/message records its source', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.tools.register(defineTool({ name: 'noop', description: '', @@ -438,7 +439,7 @@ describe('MEDIUM: misc registry and config fixes', () => { it('send() owns content and source before notification and delivery', async () => { const adapter = new MockAdapter([textResponse('done')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('owned-send'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('owned-send'), { model: 'mock' }) const content = [{ type: 'text' as const, text: 'accepted-send' }] const source = { kind: 'plugin' as const, plugin: 'accepted-source' } let notifiedContent: ContentBlock[] | undefined @@ -474,7 +475,7 @@ describe('MEDIUM: misc registry and config fixes', () => { it('running steer() owns content and source before notification and delivery', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'gate', {}), textResponse('done')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('owned-steer'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('owned-steer'), { model: 'mock' }) const entered = Promise.withResolvers() const release = Promise.withResolvers() ctx.tools.register(defineTool({ @@ -528,7 +529,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(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -544,7 +545,7 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', () ctx2.llm.registerAdapter(['mock'], second) const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] }) - const prepared = prepareReactLoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded) + const prepared = prepareReactLoopAgent(ctx2, SessionId('forked-agent'), { model: 'mock' }, seeded) const forked = prepared.agent prepared.markPublished() ctx2.effect(() => prepared.startDriver()) @@ -591,7 +592,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(AgentId('a-finish-error'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-finish-error'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -616,7 +617,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(AgentId('a-finish-aborted'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-finish-aborted'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -634,7 +635,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(AgentId('a-finish-error-nocode'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-finish-error-nocode'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -650,7 +651,7 @@ describe('step boundary publication order', () => { it('the step/start event is in session.events when its session/event listener fires', async () => { const adapter = new MockAdapter([textResponse('done')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-step-order'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-step-order'), { model: 'mock' }) // Session.append pushes the event BEFORE notifying session/event listeners, // so a step/start listener always finds the matching event already in the @@ -711,7 +712,7 @@ describe('turn and step boundary recovery', () => { it('a throwing step/start observer cannot change a successful turn', async () => { const adapter = new MockAdapter([textResponse('request completed')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-stepstart'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-stepstart'), { model: 'mock' }) // Session owns post-commit containment. The loop sees a successful append, // runs the request, and balances the ordinary step and turn boundaries. @@ -740,7 +741,7 @@ describe('turn and step boundary recovery', () => { it('a pre-commit step/start validation failure does not invent a step boundary', async () => { const adapter = new MockAdapter([textResponse('never reached')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-stepstart-veto'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-stepstart-veto'), { model: 'mock' }) let rejected = false ctx.on('internal/dispatch', (_mode, name, args) => { if (name !== 'session/event') return @@ -771,7 +772,7 @@ describe('turn and step boundary recovery', () => { const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider failed' } }] const adapter = new MockAdapter([errorStream]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-turnend-veto'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-turnend-veto'), { model: 'mock' }) let rejected = false ctx.on('internal/dispatch', (_mode, name, args) => { if (name !== 'session/event') return @@ -805,7 +806,7 @@ describe('turn and step boundary recovery', () => { it('a one-shot step/end validation failure keeps the step open until retry succeeds', async () => { const adapter = new MockAdapter([textResponse('completed before close validation')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-stepend-veto'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-stepend-veto'), { model: 'mock' }) let rejected = false ctx.on('internal/dispatch', (_mode, name, args) => { if (name !== 'session/event') return @@ -839,7 +840,7 @@ describe('turn and step boundary recovery', () => { const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }] const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-errorlistener'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-errorlistener'), { model: 'mock' }) let threw = false ctx.on('agent/error', () => { if (!threw) { threw = true; throw new Error('boom error-listener') } }) @@ -872,7 +873,7 @@ describe('turn and step boundary recovery', () => { const ctx = await balancedHarness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('a-dispose'), { model: 'mock' }) + agent = inner.agentLoop.create(SessionId('a-dispose'), { model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -901,7 +902,7 @@ describe('turn and step boundary recovery', () => { const ctx = await balancedHarness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('a-prestep-dispose-throw'), { model: 'mock' }) + agent = inner.agentLoop.create(SessionId('a-prestep-dispose-throw'), { model: 'mock' }) }, { inject: ['agentLoop'] })) let threw = false @@ -935,7 +936,7 @@ describe('turn and step boundary recovery', () => { it('a throwing turn/start observer cannot starve the loop or later turns', async () => { const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-preturn'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-preturn'), { model: 'mock' }) let threw = false ctx.on('session/event', (_session, event) => { @@ -966,7 +967,7 @@ describe('turn and step boundary recovery', () => { it('a throwing step/end observer cannot rewrite the turn outcome', async () => { const adapter = new MockAdapter([textResponse('all good'), textResponse('turn 2 ok')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-stepend-throw'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-stepend-throw'), { model: 'mock' }) let threw = false ctx.on('session/event', (_s, event) => { @@ -1008,7 +1009,7 @@ describe('turn and step boundary recovery', () => { const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }] const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-stependthrow'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-stependthrow'), { model: 'mock' }) let threw = false ctx.on('session/event', (_s, event) => { @@ -1038,7 +1039,7 @@ describe('turn and step boundary recovery', () => { // boundary stays authoritative and the loop continues normally. const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-turnendappend'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-turnendappend'), { model: 'mock' }) let threw = false ctx.on('session/event', (_s, event) => { @@ -1085,7 +1086,7 @@ describe('tool result call identity', () => { return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'ok' }] }) }, { prepend: true }) - const agent = ctx.agentLoop.create(AgentId('a-callid'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-callid'), { model: 'mock' }) send(agent, 'use tool') await waitForIdle(ctx, agent) @@ -1120,7 +1121,7 @@ describe('surface: assistant/message omits sourceEventSeqs when no chunks stream const adapter = new MockAdapter([[]]) const ctx = await harness(adapter) await ctx.plugin(Invariants) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _next) => ({ role: 'assistant' as const, @@ -1171,7 +1172,7 @@ describe('disposal and cancellation during pre-step assembly', () => { let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('a-dispose-assemble'), { model: 'mock' }) + agent = inner.agentLoop.create(SessionId('a-dispose-assemble'), { model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -1227,7 +1228,7 @@ describe('disposal and cancellation during pre-step assembly', () => { let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('a-cancel-assemble'), { model: 'mock' }) + agent = inner.agentLoop.create(SessionId('a-cancel-assemble'), { model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -1282,7 +1283,7 @@ describe('disposal and cancellation during pre-step assembly', () => { let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('a-dispose-prestep'), { model: 'mock' }) + agent = inner.agentLoop.create(SessionId('a-dispose-prestep'), { model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -1334,7 +1335,7 @@ describe('disposal and cancellation during pre-step assembly', () => { let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('a-cancel-prestep'), { model: 'mock' }) + agent = inner.agentLoop.create(SessionId('a-cancel-prestep'), { model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -1384,7 +1385,7 @@ describe('disposal and cancellation during pre-step assembly', () => { let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('a-dispose-no-leak'), { model: 'mock' }) + agent = inner.agentLoop.create(SessionId('a-dispose-no-leak'), { model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 2c478ad1f0..c68d96e525 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -4,7 +4,8 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId, agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' + import type { Agent } from '@deepseek-ai/dsh-agent' import { scopeOf } from '@deepseek-ai/dsh-scope' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' @@ -57,33 +58,31 @@ function disposeCurrentLifecycle(ownerCtx: Context): void { } describe('agent scope lifecycle', () => { - it('rejects an already-aborted creation signal before publishing either identity', async () => { + it('rejects an already-aborted creation signal before publishing either object', async () => { const ctx = await harness() const reason = new Error('cancelled before creation') const controller = new AbortController() controller.abort(reason) await expect(ctx.agents.create({ - agentId: AgentId('pre-aborted'), sessionId: SessionId('pre-aborted-s'), signal: controller.signal, })).rejects.toBe(reason) - expect(ctx.agents.get(AgentId('pre-aborted'))).toBeUndefined() + expect(ctx.agents.get(SessionId('pre-aborted-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('pre-aborted-s'))).toBeUndefined() const valueController = new AbortController() valueController.abort('plain cancellation reason') await expect(ctx.agents.create({ - agentId: AgentId('pre-aborted-value'), sessionId: SessionId('pre-aborted-value-s'), signal: valueController.signal, })).rejects.toMatchObject({ - message: 'agent "pre-aborted-value" creation aborted', + message: 'agent "pre-aborted-value-s" creation aborted', cause: 'plain cancellation reason', }) - expect(ctx.agents.get(AgentId('pre-aborted-value'))).toBeUndefined() + expect(ctx.agents.get(SessionId('pre-aborted-value-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('pre-aborted-value-s'))).toBeUndefined() await ctx.fiber.dispose() }) @@ -100,12 +99,11 @@ describe('agent scope lifecycle', () => { }) await expect(ctx.agents.create({ - agentId: AgentId('prepare-abort'), sessionId: SessionId('prepare-abort-s'), signal: controller.signal, })).rejects.toBe(reason) - expect(ctx.agents.get(AgentId('prepare-abort'))).toBeUndefined() + expect(ctx.agents.get(SessionId('prepare-abort-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('prepare-abort-s'))).toBeUndefined() await ctx.fiber.dispose() }) @@ -124,7 +122,7 @@ describe('agent scope lifecycle', () => { thrown = createFailure let createCaught: unknown try { - ctx.agentLoop.create(AgentId('unknown-create')) + ctx.agentLoop.create(SessionId('unknown-create')) } catch (error: unknown) { createCaught = error } @@ -133,28 +131,27 @@ describe('agent scope lifecycle', () => { const ownedFailure = { source: 'createAgent' } thrown = ownedFailure await expect(ctx.agents.create({ - agentId: AgentId('unknown-owned-create'), sessionId: SessionId('unknown-owned-create-s'), })).rejects.toBe(ownedFailure) - expect(ctx.agents.get(AgentId('unknown-create'))).toBeUndefined() - expect(ctx.agents.get(AgentId('unknown-owned-create'))).toBeUndefined() + expect(ctx.agents.get(SessionId('unknown-create'))).toBeUndefined() + expect(ctx.agents.get(SessionId('unknown-owned-create-s'))).toBeUndefined() await ctx.fiber.dispose() }) it('wires agent.ctx: tagged with the agent, DX field set, ctx.agent safe elsewhere', async () => { const ctx = await harness() - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) expect(scopeOf(agent.ctx)).toBe(agent) expect(agent.ctx.agent).toBe(agent) // The root accessor default: a plain context answers undefined, not a throw. expect(ctx.agent).toBeUndefined() - await ctx.agents.get(AgentId('a1'))?.whenIdle() + await ctx.agents.get(SessionId('a1'))?.whenIdle() }) it('scoped registrations live in the agent world and die with the agent', async () => { const ctx = await harness() - const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { model: 'mock' } }) + const handle = await ctx.agents.create({ sessionId: SessionId('s1'), agentOptions: { model: 'mock' } }) const { agent } = handle agent.ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You run tests.' }) agent.ctx.tools.register({ @@ -179,8 +176,8 @@ describe('agent scope lifecycle', () => { it('agent.ctx listeners hear only their own agent (scoped dispatch end to end)', async () => { const ctx = await harness(new MockAdapter([textResponse('one'), textResponse('two')])) - const a = ctx.agentLoop.create(AgentId('a'), { model: 'mock' }) - const b = ctx.agentLoop.create(AgentId('b'), { model: 'mock' }) + const a = ctx.agentLoop.create(SessionId('a'), { model: 'mock' }) + const b = ctx.agentLoop.create(SessionId('b'), { model: 'mock' }) const heard: string[] = [] a.ctx.on('agent/status', (subject, status) => void heard.push(`a-sees:${subject.id}:${status}`)) @@ -210,7 +207,6 @@ describe('agent scope lifecycle', () => { }) const handle = await ctx.agents.create({ - agentId: AgentId('child'), sessionId: SessionId('child-s'), agentOptions: { model: 'mock' }, setup: async (agentCtx) => { @@ -224,14 +220,14 @@ describe('agent scope lifecycle', () => { await handle.dispose() }) - it('keeps both identities unpublished until async setup completes, then announces in order', async () => { + it('keeps both objects unpublished until async setup completes, then announces in order', async () => { const ctx = await harness() const gate = Promise.withResolvers() const setupStarted = Promise.withResolvers() const order: string[] = [] ctx.on('session/created', (session) => { expect(ctx.sessions.get(session.id)).toBe(session) - expect(ctx.agents.get(AgentId('atomic'))?.session).toBe(session) + expect(ctx.agents.get(session.id)?.session).toBe(session) order.push('session/created') }) ctx.on('agent/created', () => void order.push('agent/created')) @@ -239,11 +235,10 @@ describe('agent scope lifecycle', () => { const acceptedOptions = { model: 'mock' } const creating = ctx.agents.create({ - agentId: AgentId('atomic'), - sessionId: SessionId('atomic-s'), + sessionId: SessionId('atomic'), agentOptions: acceptedOptions, setup: async (agentCtx) => { - expect(agentCtx.agent?.id).toBe(AgentId('atomic')) + expect(agentCtx.agent?.id).toBe(SessionId('atomic')) agentCtx.on('session/created', () => void order.push('setup-listener:session/created')) agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created')) order.push('setup:start') @@ -253,7 +248,7 @@ describe('agent scope lifecycle', () => { }, }) await setupStarted.promise - expect(ctx.agents.get(AgentId('atomic'))).toBeUndefined() + expect(ctx.agents.get(SessionId('atomic'))).toBeUndefined() expect(ctx.sessions.get(SessionId('atomic-s'))).toBeUndefined() expect(order).toEqual(['setup:start']) gate.resolve(undefined) @@ -281,16 +276,14 @@ describe('agent scope lifecycle', () => { if (started === 2) bothStarted.resolve(undefined) await gate.promise } - const agentId = AgentId('concurrent-final-enter') + const sessionId = SessionId('concurrent-final-enter') const first = ctx.agents.create({ - agentId, - sessionId: SessionId('concurrent-final-enter-a'), + sessionId, agentOptions: { model: 'mock' }, setup, }) const second = ctx.agents.create({ - agentId, - sessionId: SessionId('concurrent-final-enter-b'), + sessionId, agentOptions: { model: 'mock' }, setup, }) @@ -304,7 +297,7 @@ describe('agent scope lifecycle', () => { const rejected = outcomes.filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected') expect(fulfilled).toHaveLength(1) expect(rejected).toHaveLength(1) - expect(String(rejected[0]!.reason)).toMatch(/already registered/) + expect(String(rejected[0]!.reason)).toMatch(/already exists/) expect(ctx.agents.list()).toEqual([fulfilled[0]!.value.agent]) expect(ctx.sessions.list()).toEqual([fulfilled[0]!.value.agent.session]) @@ -318,7 +311,6 @@ describe('agent scope lifecycle', () => { const pendingController = new AbortController() const setupStarted = Promise.withResolvers() const pending = ctx.agents.create({ - agentId: AgentId('signal-pending'), sessionId: SessionId('signal-pending-s'), agentOptions: { model: 'mock' }, signal: pendingController.signal, @@ -330,12 +322,11 @@ describe('agent scope lifecycle', () => { await setupStarted.promise pendingController.abort(new Error('cancel pending creation')) await expect(pending).rejects.toThrow('cancel pending creation') - expect(ctx.agents.get(AgentId('signal-pending'))).toBeUndefined() + expect(ctx.agents.get(SessionId('signal-pending-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('signal-pending-s'))).toBeUndefined() const liveController = new AbortController() const live = await ctx.agents.create({ - agentId: AgentId('signal-live'), sessionId: SessionId('signal-live-s'), agentOptions: { model: 'mock' }, signal: liveController.signal, @@ -358,7 +349,6 @@ describe('agent scope lifecycle', () => { let creating!: ReturnType const owner = await ctx.plugin(Object.assign((inner: Context) => { creating = inner.agents.create({ - agentId: AgentId('owner-race'), sessionId: SessionId('owner-race-s'), agentOptions: { model: 'mock' }, setup: async () => { @@ -372,7 +362,7 @@ describe('agent scope lifecycle', () => { await owner.dispose() await expect(creating).rejects.toThrow(/owner disposed during setup/) expect(published).toEqual([]) - expect(ctx.agents.get(AgentId('owner-race'))).toBeUndefined() + expect(ctx.agents.get(SessionId('owner-race-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('owner-race-s'))).toBeUndefined() // Let the losing callback settle; Promise.race already observes it. gate.resolve(undefined) @@ -386,7 +376,6 @@ describe('agent scope lifecycle', () => { let creating2!: ReturnType const owner2 = await ctx.plugin(Object.assign((inner: Context) => { creating2 = inner.agents.create({ - agentId: AgentId('owner-race-2'), sessionId: SessionId('owner-race-s-2'), agentOptions: { model: 'mock' }, setup: async () => { @@ -400,7 +389,7 @@ describe('agent scope lifecycle', () => { const unload2 = owner2.dispose() await expect(creating2).rejects.toThrow(/owner disposed during setup/) await unload2 - expect(ctx.agents.get(AgentId('owner-race-2'))).toBeUndefined() + expect(ctx.agents.get(SessionId('owner-race-s-2'))).toBeUndefined() expect(ctx.sessions.get(SessionId('owner-race-s-2'))).toBeUndefined() }) @@ -413,7 +402,6 @@ describe('agent scope lifecycle', () => { ctx.on('agent/created', () => void published.push('agent/created')) const creating = ctx.agents.create({ - agentId: AgentId('factory-setup-race'), sessionId: SessionId('factory-setup-race-s'), agentOptions: { model: 'mock' }, setup: async () => { @@ -426,7 +414,7 @@ describe('agent scope lifecycle', () => { await loopFiber.dispose() await expect(creating).rejects.toThrow(/agent loop is not active/) expect(published).toEqual([]) - expect(ctx.agents.get(AgentId('factory-setup-race'))).toBeUndefined() + expect(ctx.agents.get(SessionId('factory-setup-race-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('factory-setup-race-s'))).toBeUndefined() gate.resolve(undefined) @@ -444,7 +432,6 @@ describe('agent scope lifecycle', () => { }) const creating = ctx.agents.create({ - agentId: AgentId('factory-scope-race'), sessionId: SessionId('factory-scope-race-s'), agentOptions: { model: 'mock' }, setup: () => { setupCalls += 1 }, @@ -452,7 +439,7 @@ describe('agent scope lifecycle', () => { await expect(creating).rejects.toThrow(/agent loop is not active/) await loopFiber.dispose() expect(setupCalls).toBe(0) - expect(ctx.agents.get(AgentId('factory-scope-race'))).toBeUndefined() + expect(ctx.agents.get(SessionId('factory-scope-race-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('factory-scope-race-s'))).toBeUndefined() await ctx.fiber.dispose() @@ -479,7 +466,6 @@ describe('agent scope lifecycle', () => { const owner = ctx.plugin(Object.assign((inner: Context) => { ownerFiber = inner.fiber creating = inner.agents.create({ - agentId: AgentId('caller-scope-race'), sessionId: SessionId('caller-scope-race-s'), agentOptions: { model: 'mock' }, }) @@ -495,7 +481,7 @@ describe('agent scope lifecycle', () => { await ownerDisposal await owner expect(scopeFiber?.uid).toBeNull() - expect(ctx.agents.get(AgentId('caller-scope-race'))).toBeUndefined() + expect(ctx.agents.get(SessionId('caller-scope-race-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('caller-scope-race-s'))).toBeUndefined() await owner.dispose() await ctx.fiber.dispose() @@ -511,17 +497,17 @@ describe('agent scope lifecycle', () => { void loopFiber.dispose() }) - expect(() => ctx.agentLoop.create(AgentId('config-scope-race'), { model: 'mock' })) + expect(() => ctx.agentLoop.create(SessionId('config-scope-race'), { model: 'mock' })) .toThrow(/agent loop is not active/) await loopFiber.dispose() - expect(ctx.agents.get(AgentId('config-scope-race'))).toBeUndefined() + expect(ctx.agents.get(SessionId('config-scope-race'))).toBeUndefined() expect(ctx.sessions.list()).toHaveLength(sessionsBefore) await ctx.fiber.dispose() }) it('synchronous create leaves no lifecycle state when session preparation fails', async () => { const ctx = await harness() - const id = AgentId('config-prepare-failure') + const id = SessionId('config-prepare-failure') expect(() => ctx.agentLoop.create(id, { model: 'mock' }, { cwd: 'relative' })) .toThrow(/absolute path/) @@ -542,12 +528,11 @@ describe('agent scope lifecycle', () => { }) await expect(ctx.agents.create({ - agentId: AgentId('factory-scope-throw'), sessionId: SessionId('factory-scope-throw-s'), agentOptions: { model: 'mock' }, })).rejects.toThrow('scope preparation failed') await loopFiber.dispose() - expect(ctx.agents.get(AgentId('factory-scope-throw'))).toBeUndefined() + expect(ctx.agents.get(SessionId('factory-scope-throw-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('factory-scope-throw-s'))).toBeUndefined() await ctx.fiber.dispose() @@ -556,23 +541,21 @@ describe('agent scope lifecycle', () => { it('AgentLoop unload is a structural co-owner of every live programmatic agent', async () => { const { ctx, loopFiber } = await harnessWithLoop() const loop = ctx.agentLoop - const agentId = AgentId('factory-live') + const sessionId = SessionId('factory-live') const handle = await ctx.agents.create({ - agentId, - sessionId: SessionId('factory-live-s'), + sessionId, agentOptions: { model: 'mock' }, }) await loopFiber.dispose() expect(handle.agent.status).toBe('disposed') - expect(ctx.agents.get(agentId)).toBeUndefined() - expect(ctx.sessions.get(SessionId('factory-live-s'))).toBeUndefined() - expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${agentId})`)).toEqual([]) + expect(ctx.agents.get(sessionId)).toBeUndefined() + expect(ctx.sessions.get(sessionId)).toBeUndefined() + expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${sessionId})`)).toEqual([]) // The consumer handle shares the provider's completed quiescence boundary. await handle.dispose() await expect(loop.createAgent(ctx, { - agentId: AgentId('factory-inactive'), sessionId: SessionId('factory-inactive-s'), })).rejects.toThrow('agent loop is not active') await ctx.fiber.dispose() @@ -583,7 +566,6 @@ describe('agent scope lifecycle', () => { let creating!: ReturnType const owner = await ctx.plugin(Object.assign((inner: Context) => { creating = inner.agents.create({ - agentId: AgentId('dependency-origin'), sessionId: SessionId('dependency-origin-s'), agentOptions: { model: 'mock' }, setup: (agentCtx) => { @@ -623,7 +605,7 @@ describe('agent scope lifecycle', () => { }) ctx.on('session/created', (session) => { if (session.id !== SessionId('session-created-barrier-s')) return - const agent = ctx.agents.get(AgentId('session-created-barrier'))! + const agent = ctx.agents.get(SessionId('session-created-barrier-s'))! expect(ctx.sessions.get(session.id)).toBe(session) expect(agent.session).toBe(session) agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') }) @@ -638,7 +620,6 @@ describe('agent scope lifecycle', () => { const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner creating = inner.agents.create({ - agentId: AgentId('session-created-barrier'), sessionId: SessionId('session-created-barrier-s'), agentOptions: { model: 'mock' }, }) @@ -652,7 +633,7 @@ describe('agent scope lifecycle', () => { 'session-disposed', 'scope-disposed', ]) - expect(ctx.agents.get(AgentId('session-created-barrier'))).toBeUndefined() + expect(ctx.agents.get(SessionId('session-created-barrier-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('session-created-barrier-s'))).toBeUndefined() await ctx.fiber.dispose() }) @@ -666,19 +647,19 @@ describe('agent scope lifecycle', () => { if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-created') }) ctx.on('agent/created', (agent) => { - if (agent.id !== AgentId('agent-created-barrier')) return + if (agent.id !== SessionId('agent-created-barrier-s')) return lifecycle.push('agent-created:dispose') disposeCurrentLifecycle(ownerCtx) }) ctx.on('agent/created', (agent) => { - if (agent.id !== AgentId('agent-created-barrier')) return + if (agent.id !== SessionId('agent-created-barrier-s')) return expect(ctx.agents.get(agent.id)).toBe(agent) expect(ctx.sessions.get(agent.session.id)).toBe(agent.session) agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') }) lifecycle.push('agent-created:observer') }) ctx.on('agent/disposed', (agent) => { - if (agent.id === AgentId('agent-created-barrier')) lifecycle.push('agent-disposed') + if (agent.id === SessionId('agent-created-barrier-s')) lifecycle.push('agent-disposed') }) ctx.on('session/disposed', (session) => { if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-disposed') @@ -687,7 +668,6 @@ describe('agent scope lifecycle', () => { const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner creating = inner.agents.create({ - agentId: AgentId('agent-created-barrier'), sessionId: SessionId('agent-created-barrier-s'), agentOptions: { model: 'mock' }, }) @@ -703,7 +683,7 @@ describe('agent scope lifecycle', () => { 'session-disposed', 'scope-disposed', ]) - expect(ctx.agents.get(AgentId('agent-created-barrier'))).toBeUndefined() + expect(ctx.agents.get(SessionId('agent-created-barrier-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('agent-created-barrier-s'))).toBeUndefined() await ctx.fiber.dispose() }) @@ -715,13 +695,12 @@ describe('agent scope lifecycle', () => { let creating!: ReturnType ctx.on('agent/session-start', agent => void starts.push(agent.id)) ctx.on('agent/created', (agent) => { - if (agent.id === AgentId('listener-dispose')) void ownerCtx.fiber.dispose() + if (agent.id === SessionId('listener-dispose-s')) void ownerCtx.fiber.dispose() }) const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner creating = inner.agents.create({ - agentId: AgentId('listener-dispose'), sessionId: SessionId('listener-dispose-s'), agentOptions: { model: 'mock' }, }) @@ -730,7 +709,7 @@ describe('agent scope lifecycle', () => { await expect(creating).rejects.toThrow(/owner disposed during setup/) await owner.dispose() expect(starts).toEqual([]) - expect(ctx.agents.get(AgentId('listener-dispose'))).toBeUndefined() + expect(ctx.agents.get(SessionId('listener-dispose-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('listener-dispose-s'))).toBeUndefined() await ctx.fiber.dispose() }) @@ -744,15 +723,15 @@ describe('agent scope lifecycle', () => { let scopeDisposed = false let observerSawLive = false ctx.on('agent/status', (agent, status) => { - if (agent.id === AgentId('session-start-dispose')) statuses.push(status) + if (agent.id === SessionId('session-start-dispose-s')) statuses.push(status) }) ctx.on('agent/session-start', (agent) => { - if (agent.id !== AgentId('session-start-dispose')) return + if (agent.id !== SessionId('session-start-dispose-s')) return announced = agent as ReactLoopAgent disposeCurrentLifecycle(ownerCtx) }) ctx.on('agent/session-start', (agent) => { - if (agent.id !== AgentId('session-start-dispose')) return + if (agent.id !== SessionId('session-start-dispose-s')) return expect(ctx.agents.get(agent.id)).toBe(agent) expect(ctx.sessions.get(agent.session.id)).toBe(agent.session) agent.ctx.effect(() => () => { scopeDisposed = true }) @@ -762,7 +741,6 @@ describe('agent scope lifecycle', () => { const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner creating = inner.agents.create({ - agentId: AgentId('session-start-dispose'), sessionId: SessionId('session-start-dispose-s'), agentOptions: { model: 'mock' }, }) @@ -775,7 +753,7 @@ describe('agent scope lifecycle', () => { expect(observerSawLive).toBe(true) expect(scopeDisposed).toBe(true) expect(announced.session.events).toEqual([]) - expect(ctx.agents.get(AgentId('session-start-dispose'))).toBeUndefined() + expect(ctx.agents.get(SessionId('session-start-dispose-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('session-start-dispose-s'))).toBeUndefined() await ctx.fiber.dispose() }) @@ -787,7 +765,6 @@ describe('agent scope lifecycle', () => { ctx.on('agent/created', () => void published.push('agent/created')) ctx.on('agent/session-start', () => void published.push('agent/session-start')) await expect(ctx.agents.create({ - agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' }, setup: async () => { @@ -798,13 +775,13 @@ describe('agent scope lifecycle', () => { // Nothing leaked: no agent, no session, and the ids are reusable. expect(published).toEqual([]) - expect(ctx.agents.get(AgentId('bad'))).toBeUndefined() + expect(ctx.agents.get(SessionId('bad-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined() - const retry = await ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } }) + const retry = await ctx.agents.create({ sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } }) await retry.dispose() }) - it('rejects an exotic durable seed before publishing either identity', async () => { + it('rejects an exotic durable seed before publishing either object', async () => { const ctx = await harness() const published: string[] = [] ctx.on('session/created', () => { published.push('session') }) @@ -817,17 +794,15 @@ describe('agent scope lifecycle', () => { }] as unknown as SessionEvent[] await expect(ctx.agents.create({ - agentId: AgentId('exotic-seed'), sessionId: SessionId('exotic-seed-session'), agentOptions: { model: 'mock' }, seed, })).rejects.toThrow(/seed event at index 0 is not losslessly JSON-serializable/) expect(published).toEqual([]) - expect(ctx.agents.get(AgentId('exotic-seed'))).toBeUndefined() + expect(ctx.agents.get(SessionId('exotic-seed-session'))).toBeUndefined() expect(ctx.sessions.get(SessionId('exotic-seed-session'))).toBeUndefined() const retry = await ctx.agents.create({ - agentId: AgentId('exotic-seed'), sessionId: SessionId('exotic-seed-session'), agentOptions: { model: 'mock' }, }) @@ -843,13 +818,13 @@ describe('agent scope lifecycle', () => { if (boom) { boom = false; throw new Error('boom created') } }) await expect(ctx.agents.create({ - agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' }, + sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' }, })).rejects.toThrow('boom created') - expect(ctx.agents.get(AgentId('bad'))).toBeUndefined() + expect(ctx.agents.get(SessionId('bad-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined() expect(disposed).toEqual([]) // inserted but never announced: no impossible disposed edge // The rollback also disposed the scope fiber: re-creating works cleanly. - const retry = await ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } }) + const retry = await ctx.agents.create({ sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } }) expect(scopeOf(retry.agent.ctx)).toBe(retry.agent) await retry.dispose() }) @@ -866,18 +841,17 @@ describe('agent scope lifecycle', () => { ctx.on('agent/disposed', (agent) => { lifecycle.push(`agent-disposed:${agent.id}`) }) await expect(ctx.agents.create({ - agentId: AgentId('partial-agent'), sessionId: SessionId('partial-session'), agentOptions: { model: 'mock' }, })).rejects.toThrow('agent observer failed') expect(lifecycle).toEqual([ 'session-created:partial-session', - 'agent-created:partial-agent', - 'agent-disposed:partial-agent', + 'agent-created:partial-session', + 'agent-disposed:partial-session', 'session-disposed:partial-session', ]) - expect(ctx.agents.get(AgentId('partial-agent'))).toBeUndefined() + expect(ctx.agents.get(SessionId('partial-session'))).toBeUndefined() expect(ctx.sessions.get(SessionId('partial-session'))).toBeUndefined() }) @@ -892,23 +866,23 @@ describe('agent scope lifecycle', () => { } }) - expect(() => ctx.agentLoop.create(AgentId('config-bad'), { model: 'mock' })) + expect(() => ctx.agentLoop.create(SessionId('config-bad'), { model: 'mock' })) .toThrow('config publish failed') - expect(ctx.agents.get(AgentId('config-bad'))).toBeUndefined() + expect(ctx.agents.get(SessionId('config-bad'))).toBeUndefined() expect(ctx.sessions.list()).toHaveLength(sessionsBefore) }) it('registrations through a disposed agent ctx throw INACTIVE_EFFECT', async () => { const ctx = await harness() - const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { model: 'mock' } }) + const handle = await ctx.agents.create({ sessionId: SessionId('s1'), agentOptions: { model: 'mock' } }) await handle.dispose() expect(() => handle.agent.ctx.on('agent/status', () => {})).toThrow(/inactive context/) }) it('agentEvents fuses carrier and subject for custom drivers', async () => { const ctx = await harness() - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - const other = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) + const other = ctx.agentLoop.create(SessionId('a2'), { model: 'mock' }) const heard: string[] = [] agent.ctx.on('agent/error', (subject: Agent, turn: number) => void heard.push(`${subject.id}:${turn}`)) @@ -921,7 +895,7 @@ describe('agent scope lifecycle', () => { const ctx = await harness() let handle!: Awaited> const owner = await ctx.plugin(Object.assign(async (inner: Context) => { - handle = await inner.agents.create({ agentId: AgentId('o1'), sessionId: SessionId('o1-s'), agentOptions: { model: 'mock' } }) + handle = await inner.agents.create({ sessionId: SessionId('o1-s'), agentOptions: { model: 'mock' } }) }, { inject: ['agents'] })) const { agent } = handle @@ -930,7 +904,7 @@ describe('agent scope lifecycle', () => { if (event.type === 'turn/end') order.push('turn-end') }) ctx.on('agent/disposed', () => { - order.push(`disposed(listed=${ctx.agents.get(AgentId('o1')) !== undefined})`) + order.push(`disposed(listed=${ctx.agents.get(SessionId('o1-s')) !== undefined})`) order.push(`session-still-stored=${ctx.sessions.get(SessionId('o1-s')) !== undefined}`) }) @@ -955,7 +929,7 @@ describe('agent scope lifecycle', () => { const ctx = await harness() let handle!: Awaited> const owner = await ctx.plugin(Object.assign(async (inner: Context) => { - handle = await inner.agents.create({ agentId: AgentId('h1'), sessionId: SessionId('h1-s'), agentOptions: { model: 'mock' } }) + handle = await inner.agents.create({ sessionId: SessionId('h1-s'), agentOptions: { model: 'mock' } }) }, { inject: ['agents'] })) const teardownDone: string[] = [] @@ -967,23 +941,22 @@ describe('agent scope lifecycle', () => { // actually finished (the raw wrapper returns undefined on a repeat call). await handle.dispose() expect(teardownDone).toContain('unregistered') - expect(ctx.agents.get(AgentId('h1'))).toBeUndefined() + expect(ctx.agents.get(SessionId('h1-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('h1-s'))).toBeUndefined() await unload }) it('successful handle disposal retires its caller ownership effect', async () => { const ctx = await harness() - const agentId = AgentId('retired-owner-effect') + const sessionId = SessionId('retired-owner-effect') const handle = await ctx.agents.create({ - agentId, - sessionId: SessionId('retired-owner-effect-s'), + sessionId, agentOptions: { model: 'mock' }, }) - expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain(`agentLoop.owner(${agentId})`) + expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain(`agentLoop.owner(${sessionId})`) await handle.dispose() - expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${agentId})`)).toEqual([]) + expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${sessionId})`)).toEqual([]) await ctx.fiber.dispose() }) @@ -994,7 +967,6 @@ describe('agent scope lifecycle', () => { let handle!: Awaited> const owner = await ctx.plugin(Object.assign(async (inner: Context) => { handle = await inner.agents.create({ - agentId: AgentId('manual-first'), sessionId: SessionId('manual-first-s'), agentOptions: { model: 'mock' }, setup(agentCtx) { @@ -1014,7 +986,7 @@ describe('agent scope lifecycle', () => { expect(ownerSettled).toBe(false) gate.resolve(undefined) await Promise.all([disposing, unloading]) - expect(ctx.agents.get(AgentId('manual-first'))).toBeUndefined() + expect(ctx.agents.get(SessionId('manual-first-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('manual-first-s'))).toBeUndefined() await ctx.fiber.dispose() }) @@ -1024,13 +996,11 @@ describe('agent scope lifecycle', () => { const gate = Promise.withResolvers() const cleanupStarted = Promise.withResolvers() const sessionDisposed = Promise.withResolvers() - const agentId = AgentId('quiescent-reuse') - const sessionId = SessionId('quiescent-reuse-s') + const sessionId = SessionId('quiescent-reuse') ctx.on('session/disposed', (session) => { if (session.id === sessionId) sessionDisposed.resolve(undefined) }) const first = await ctx.agents.create({ - agentId, sessionId, agentOptions: { model: 'mock' }, setup(agentCtx) { @@ -1043,10 +1013,10 @@ describe('agent scope lifecycle', () => { const disposing = first.dispose() await Promise.all([sessionDisposed.promise, cleanupStarted.promise]) - expect(ctx.agents.get(agentId)).toBeUndefined() + expect(ctx.agents.get(sessionId)).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() - const replacement = await ctx.agents.create({ agentId, sessionId, agentOptions: { model: 'mock' } }) - expect(ctx.agents.get(agentId)).toBe(replacement.agent) + const replacement = await ctx.agents.create({ sessionId, agentOptions: { model: 'mock' } }) + expect(ctx.agents.get(sessionId)).toBe(replacement.agent) expect(ctx.sessions.get(sessionId)).toBe(replacement.agent.session) gate.resolve(undefined) @@ -1058,7 +1028,6 @@ describe('agent scope lifecycle', () => { it('handle.dispose() awaits an idle-injection flush before unregistering or detaching', async () => { const ctx = await harness() const handle = await ctx.agents.create({ - agentId: AgentId('idle-flush'), sessionId: SessionId('idle-flush-s'), agentOptions: { model: 'mock' }, }) @@ -1077,12 +1046,12 @@ describe('agent scope lifecycle', () => { const disposal = handle.dispose().then(() => { disposed = true }) await new Promise(resolve => setTimeout(resolve, 0)) expect(disposed).toBe(false) - expect(ctx.agents.get(AgentId('idle-flush'))).toBe(handle.agent) + expect(ctx.agents.get(SessionId('idle-flush-s'))).toBe(handle.agent) expect(ctx.sessions.get(SessionId('idle-flush-s'))).toBe(handle.agent.session) gate.resolve(undefined) await disposal - expect(ctx.agents.get(AgentId('idle-flush'))).toBeUndefined() + expect(ctx.agents.get(SessionId('idle-flush-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('idle-flush-s'))).toBeUndefined() }) }) diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index d9b0a87e1d..1085f01a4c 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -10,11 +10,12 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { foldRequestHeader } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' import SystemPrompt, { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -57,7 +58,7 @@ async function runTurn(registrationOrder: string[], toolOrder?: SystemPromptConf const adapter = new MockAdapter([textResponse('done')]) const ctx = await harness(adapter, toolOrder) for (const name of registrationOrder) registerNamed(ctx, name) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) return { ctx, agent, adapter } @@ -103,7 +104,7 @@ describe('loop-level canonical tool order', () => { registerNamed(ctx, 'alpha') const errors: Error[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) diff --git a/packages/core/agent-loop/tests/turn-stop.spec.ts b/packages/core/agent-loop/tests/turn-stop.spec.ts index c275fee88c..c7823a2aa5 100644 --- a/packages/core/agent-loop/tests/turn-stop.spec.ts +++ b/packages/core/agent-loop/tests/turn-stop.spec.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { type TurnEndReason } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId, type ContinuationStop } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type ContinuationStop } from '@deepseek-ai/dsh-agent' + import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -45,7 +46,7 @@ describe('agent/turn-stop', () => { textResponse('must not be requested'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('terminal-steering'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('terminal-steering'), { model: 'mock' }) agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' })) let steered = false @@ -72,7 +73,7 @@ describe('agent/turn-stop', () => { textResponse('must not become a late-steering turn'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('terminal-flush-steering'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('terminal-flush-steering'), { model: 'mock' }) agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' })) let injected = false @@ -98,7 +99,7 @@ describe('agent/turn-stop', () => { textResponse('queued follow-up answer'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('terminal-flush-send'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('terminal-flush-send'), { model: 'mock' }) agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' })) let queued = false @@ -124,8 +125,8 @@ describe('agent/turn-stop', () => { ]) const ctx = await harness(adapter) registerEcho(ctx) - const stopped = ctx.agentLoop.create(AgentId('stopped'), { model: 'mock' }) - const ordinary = ctx.agentLoop.create(AgentId('ordinary'), { model: 'mock' }) + const stopped = ctx.agentLoop.create(SessionId('stopped'), { model: 'mock' }) + const ordinary = ctx.agentLoop.create(SessionId('ordinary'), { model: 'mock' }) stopped.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' })) await send(stopped) @@ -145,7 +146,7 @@ describe('agent/turn-stop', () => { ]) const ctx = await harness(adapter) registerEcho(ctx) - const agent = ctx.agentLoop.create(AgentId('owned-listener'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('owned-listener'), { model: 'mock' }) const disposeStop = agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' })) await send(agent, 'first turn') @@ -162,7 +163,7 @@ describe('agent/turn-stop', () => { textResponse('healthy later turn'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('bad-policy'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('bad-policy'), { model: 'mock' }) const reasons: TurnEndReason[] = [] const errors: string[] = [] ctx.on('session/event', (session, event) => { diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 9ee324b0c3..4eddb050d5 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -12,7 +12,7 @@ The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh- - `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber. - Advanced ordered lifecycle: `enter(agent): () => void` performs the authoritative ID collision check and inserts without announcing; `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`. -- `ctx.agents.get(id: AgentId): Agent | undefined` +- `ctx.agents.get(id: SessionId): Agent | undefined` - `ctx.agents.list(): Agent[]` #### Factory seam (creation) diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index edb7f59a37..1306e8004d 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -9,7 +9,7 @@ import { Context, getTraceable, Service, symbols } from 'cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' -import type { Agent, AgentId, AgentOptions } from './types.ts' +import type { Agent, AgentOptions } from './types.ts' export * from './types.ts' export { agentEvents, assembleContextFor } from './dispatch.ts' @@ -33,15 +33,13 @@ declare module 'cordis' { /** * Options for programmatically creating an agent through the registry factory - * ({@link AgentRegistry.create}). The caller supplies the live `sessionId` - * (e.g. an ACP-generated id) and optional session metadata (the validated - * `cwd`, fork lineage); the factory creates the session, the agent, and wires - * them together. + * ({@link AgentRegistry.create}). The caller supplies the single live + * `sessionId` shared by the agent registry and session log (e.g. an + * ACP-generated id), plus optional session metadata (the validated `cwd`, fork + * lineage); the factory creates the session and agent under that identity. */ export interface CreateAgentOptions { - /** The agent's id (the registry handle). */ - readonly agentId: AgentId - /** The live session's id (NOT derived from agentId). */ + /** The live agent/session identity. */ readonly sessionId: SessionId /** * Session creation metadata: validated absolute `cwd`, `parentSession` @@ -93,9 +91,7 @@ export interface CreateAgentOptions { * ({@link AgentRegistry.resume}). */ export interface ResumeAgentOptions { - /** The agent's id (the registry handle). */ - readonly agentId: AgentId - /** The persisted session id to load and resume on. */ + /** The persisted session id to load and use as the live agent/session identity. */ readonly resumeSessionId: SessionId /** Per-agent options (model, …). */ readonly agentOptions?: AgentOptions @@ -180,7 +176,7 @@ const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plug /** All mutable lifecycle state for one exact registry entry. */ interface AgentEntry { - readonly id: AgentId + readonly id: SessionId readonly agent: Agent readonly carrier: Scoped announced: boolean @@ -201,7 +197,7 @@ interface FactorySlot { * {@link setFactory}. */ export class AgentRegistry extends Service { - private store = new Map() + private store = new Map() private factory: FactorySlot | undefined constructor(ctx: Context) { @@ -257,7 +253,7 @@ export class AgentRegistry extends Service { * agent): this constructs the agent and its session. Rejects if no factory is * registered or creation/setup fails. The resolved {@link AgentHandle} lets * the owner tear down exactly this agent. - * @param options - agent id, session id/seed/metadata, and agent options. + * @param options - shared identity, session seed/metadata, and agent options. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ async create(options: CreateAgentOptions): Promise { @@ -428,10 +424,10 @@ export class AgentRegistry extends Service { /** * Look up a live agent. - * @param id - the agent id to look up. + * @param id - the shared agent/session id to look up. * @returns the agent, or undefined when no live agent has that id. */ - get(id: AgentId): Agent | undefined { + get(id: SessionId): Agent | undefined { return this.store.get(id)?.agent } diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index a990f0d67d..b0fb78ad66 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -32,7 +32,7 @@ * event. A turn/step boundary is a durable fact: it lives in the session log * and is read off the `session/event` feed — it is NOT mirrored as an `agent/*` * emit. A consumer that needs the `Agent` handle (or its short id) at a boundary - * keeps a session-id→agent map from `agent/created`/`agent/disposed`. + * looks up the agent directly by the event's session id. * See `docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md` * and `docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md`. * @@ -45,25 +45,12 @@ * @module @deepseek-ai/dsh-agent/types */ -import type { Branded } from '@deepseek-ai/dsh-brand' import type { Context } from 'cordis' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { ContentBlock, LlmCallConfig, Message, MessageSource } from '@deepseek-ai/dsh-llm' +import type { Session, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' -/** Identifies one live agent in the registry. */ -export type AgentId = Branded<'AgentId'> - -/** - * Brand a string as an {@link AgentId}. - * @param id - the raw agent id string. - * @returns the same string, branded (a compile-time cast — no runtime cost). - */ -export function AgentId(id: string): AgentId { - return id as AgentId -} -import type { Session } from '@deepseek-ai/dsh-session' - declare module '@deepseek-ai/dsh-system-prompt' { interface AssembleContext { /** @@ -186,7 +173,8 @@ export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' * package should depend on the implementation. */ export interface Agent { - readonly id: AgentId + /** The single identity shared with {@link session}. */ + readonly id: SessionId readonly options: AgentOptions readonly session: Session readonly status: AgentStatus diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 5d541d56a8..cba833b212 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -2,11 +2,12 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context, Service, symbols } from 'cordis' import type { Events } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { AgentId, agentEvents } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' + import type { Agent, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent' function stubAgent(rawId: string): Agent { - const id = AgentId(rawId) + const id = SessionId(rawId) return { id, options: {}, @@ -57,7 +58,7 @@ describe('AgentRegistry', () => { ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`)) expect(() => ctx.agents.register(stubAgent('vetoed'))).toThrow('creation veto') - expect(ctx.agents.get(AgentId('vetoed'))).toBeUndefined() + expect(ctx.agents.get(SessionId('vetoed'))).toBeUndefined() expect(lifecycle).toEqual(['created:vetoed', 'disposed:vetoed']) }) @@ -158,11 +159,11 @@ describe('AgentRegistry factory seam', () => { const factory: AgentFactory = { async createAgent(ownerCtx, options) { calls.create.push({ ownerCtx, options }) - return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() } + return { agent: stubAgent(options.sessionId), dispose: () => Promise.resolve() } }, async resume(ownerCtx, options) { calls.resume.push({ ownerCtx, options }) - return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() } + return { agent: stubAgent(options.resumeSessionId), dispose: () => Promise.resolve() } }, } return { factory, calls } @@ -171,15 +172,15 @@ describe('AgentRegistry factory seam', () => { it('requires a factory and delegates through the calling context', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) - await expect(ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).rejects.toThrow(/no agent factory/) + await expect(ctx.agents.create({ sessionId: SessionId('s') })).rejects.toThrow(/no agent factory/) const { factory, calls } = stubFactory() ctx.agents.setFactory(factory) let callerFiber: Context['fiber'] | undefined await ctx.plugin(Object.assign(async (inner: Context) => { callerFiber = inner.fiber - await inner.agents.create({ agentId: AgentId('create'), sessionId: SessionId('create-s') }) - await inner.agents.resume({ agentId: AgentId('resume'), resumeSessionId: SessionId('resume-s') }) + await inner.agents.create({ sessionId: SessionId('create-s') }) + await inner.agents.resume({ resumeSessionId: SessionId('resume-s') }) }, { inject: ['agents'] })) expect(calls.create[0]?.ownerCtx.fiber).toBe(callerFiber) expect(calls.resume[0]?.ownerCtx.fiber).toBe(callerFiber) @@ -192,9 +193,9 @@ describe('AgentRegistry factory seam', () => { inner.agents.setFactory(stubFactory().factory) expect(() => inner.agents.setFactory(stubFactory().factory)).toThrow(/already registered/) }, { inject: ['agents'] })) - await expect(ctx.agents.create({ agentId: AgentId('before'), sessionId: SessionId('before-s') })).resolves.toBeDefined() + await expect(ctx.agents.create({ sessionId: SessionId('before-s') })).resolves.toBeDefined() await owner.dispose() - await expect(ctx.agents.create({ agentId: AgentId('after'), sessionId: SessionId('after-s') })).rejects.toThrow(/no agent factory/) + await expect(ctx.agents.create({ sessionId: SessionId('after-s') })).rejects.toThrow(/no agent factory/) }) it('canonicalizes an already traced Service before tracing it for the caller', async () => { @@ -214,18 +215,18 @@ describe('AgentRegistry factory seam', () => { } async createAgent(_ownerCtx: Context, options: CreateAgentOptions) { this.calls().push('create') - return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() } + return { agent: stubAgent(options.sessionId), dispose: () => Promise.resolve() } } async resume(_ownerCtx: Context, options: ResumeAgentOptions) { this.calls().push('resume') - return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() } + return { agent: stubAgent(options.resumeSessionId), dispose: () => Promise.resolve() } } } await ctx.plugin(TracedFactory) const traced = (ctx as Context & { tracedFactory: TracedFactory }).tracedFactory ctx.agents.setFactory(traced) - await ctx.agents.create({ agentId: AgentId('create'), sessionId: SessionId('create-s') }) - await ctx.agents.resume({ agentId: AgentId('resume'), resumeSessionId: SessionId('resume-s') }) + await ctx.agents.create({ sessionId: SessionId('create-s') }) + await ctx.agents.resume({ resumeSessionId: SessionId('resume-s') }) const raw = (traced as unknown as { [symbols.original]?: TracedFactory })[symbols.original] expect(states.get(raw!)).toEqual(['create', 'resume']) }) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 06669ca85c..cb6e1304c3 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -288,7 +288,12 @@ export class Session { */ readonly header: SessionHeader - constructor(public readonly id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) { + /** The session identity, derived from its durable header's single copy. */ + get id(): SessionId { + return this.header.id + } + + constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) { if (seed) { // Validate the seed to the SAME invariants `append` enforces, so a // replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index f9d84aadbf..2339657ec8 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -8,7 +8,6 @@ import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools' import type { Config, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools' -import { AgentId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEventMap } from '@deepseek-ai/dsh-session' @@ -59,7 +58,7 @@ async function setup(options: SetupOptions = {}) { /** Mint one production-shaped agent scope that can register scoped tool policy. */ async function mintAgentScope(ctx: Context, name = 'scoped'): Promise<{ scope: Scope; agent: Agent }> { - const agent = { id: AgentId(name) } as Agent + const agent = { id: SessionId(name) } as Agent let scope!: Scope await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, { inject: ['tools', 'systemPrompt'] })) diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index 6599112c2a..8b8e1cfead 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -6,9 +6,11 @@ import type { Scope } from '@deepseek-ai/dsh-scope' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import type { PreToolDecision, ToolDefinition, ToolExecution, ToolExecutionInput, ToolExecutionToken } from '@deepseek-ai/dsh-tools' -import type { Agent, AgentId } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' + import { CallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SessionId } from '@deepseek-ai/dsh-session' /** Mount the registry (with its systemPrompt dependency) on a fresh context. */ async function mount(): Promise { @@ -20,7 +22,7 @@ async function mount(): Promise { /** Mint a scope whose key doubles as a minimal Agent-like object. */ async function mintAgentScope(ctx: Context, name: string): Promise<{ scope: Scope; key: Agent }> { - const key = { id: name as AgentId } as Agent + const key = { id: name as SessionId } as Agent let scope!: Scope // The scoped context resolves services through the MINTING plugin's // dependency chain — the minter must inject what scope holders will reach @@ -62,7 +64,7 @@ describe('scoped tool registration', () => { it('files a scoped tool in its layer: visible/executable for that scope only', async () => { const ctx = await mount() const { scope, key } = await mintAgentScope(ctx, 'a') - const other = { id: 'other' as AgentId } as Agent + const other = { id: 'other' as SessionId } as Agent ctx.tools.register(tool('shared')) scope.ctx.tools.register(tool('mine')) @@ -195,7 +197,7 @@ describe('scoped execution dispatch', () => { it('an agent.ctx pre-execute listener gates only its own agent (and never subject-less calls)', async () => { const ctx = await mount() const { scope, key } = await mintAgentScope(ctx, 'a') - const other = { id: 'other' as AgentId } as Agent + const other = { id: 'other' as SessionId } as Agent ctx.tools.register(tool('t')) const seen: (string | undefined)[] = [] @@ -213,7 +215,7 @@ describe('scoped execution dispatch', () => { it('applies scoped guards after pre-execute and unwinds duplicate registrations independently', async () => { const ctx = await mount() const { scope, key } = await mintAgentScope(ctx, 'a') - const other = { id: 'other' as AgentId } as Agent + const other = { id: 'other' as SessionId } as Agent let bodyCalls = 0 ctx.tools.register({ ...tool('t'), @@ -428,7 +430,7 @@ describe('scoped execution dispatch', () => { it('uses one input snapshot for the normalized error shell', async () => { const ctx = await mount() const { scope, key } = await mintAgentScope(ctx, 'accepted') - const driftAgent = { id: 'drift' as AgentId } as Agent + const driftAgent = { id: 'drift' as SessionId } as Agent ctx.tools.register(tool('parent')) ctx.tools.register(tool('t')) let parent!: ToolExecutionToken diff --git a/packages/fs/tool-fs/tests/fs-tools.e2e.ts b/packages/fs/tool-fs/tests/fs-tools.e2e.ts index a270e7c6d8..dca48d65ab 100644 --- a/packages/fs/tool-fs/tests/fs-tools.e2e.ts +++ b/packages/fs/tool-fs/tests/fs-tools.e2e.ts @@ -3,7 +3,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import { fsHarness, waitForIdle } from './harness.ts' @@ -35,7 +34,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () => ctx = await fsHarness(workdir, SYSTEM) // agentLoop.create prepares a session with no cwd, so the provider default // (config.cwd = workdir) is the workspace. - const agent = ctx.agentLoop.create(AgentId('fs-e2e'), { model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('fs-e2e'), { model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', text: 'Create a file named note.txt containing exactly the line: status: draft. ' @@ -65,7 +64,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () => try { ctx = await fsHarness(configDir, SYSTEM) const handle = await ctx.agents.create({ - agentId: AgentId('fs-e2e-cwd'), sessionId: SessionId(`fs-e2e-cwd-${Date.now()}`), meta: { cwd: sessionDir }, agentOptions: { model: 'deepseek-v4-flash' }, diff --git a/packages/guard/repeat-tool-guard/README.md b/packages/guard/repeat-tool-guard/README.md index dc385bc033..e46f113301 100644 --- a/packages/guard/repeat-tool-guard/README.md +++ b/packages/guard/repeat-tool-guard/README.md @@ -24,8 +24,8 @@ The chain key is `(tool name, canonical arguments)` — canonicalization is a de - **Untracked calls are transparent to the chain.** A call excluded by `include`/`exclude` neither increments nor resets the counter, so `grep X → todo_write → grep X` still counts as two consecutive `grep X` when `todo_write` is excluded. This is what makes exclusion useful: bookkeeping tools interleaved into a loop must not launder it. - **Denied calls count.** Detection sits on `tools/post-execute`, which also runs for calls a `tools/pre-execute` listener denied — a model hammering a denied call is exactly the loop worth breaking. -- **Calls without an agent are ignored.** A direct `ctx.tools.execute()` caller has no model to remind and no `AgentId` to key on. -- **Per-agent keying.** The tool registry is context-level and subagents interleave through the same waterfall, so chains are keyed by `AgentId`; one agent's repetition never trips another's reminder. A user prompt (`agent/prompt-submit`) resets the submitting agent's chain; agent disposal drops its state. +- **Calls without an agent are ignored.** A direct `ctx.tools.execute()` caller has no model to remind and no live agent object to key on. +- **Per-agent keying.** The tool registry is context-level and subagents interleave through the same waterfall, so a `WeakMap` keys each chain by the live agent object; one agent's repetition never trips another's reminder. A user prompt (`agent/prompt-submit`) resets the submitting agent's chain, and object lifetime bounds the weak entry without a disposal listener. - **In-memory only.** A session resumed from persistence starts with a fresh chain — the guard is a heuristic nudge, not a logged invariant, later reminders are the accepted cost. ## Reminder delivery diff --git a/packages/guard/repeat-tool-guard/src/index.ts b/packages/guard/repeat-tool-guard/src/index.ts index 6a5662d693..d51df1d829 100644 --- a/packages/guard/repeat-tool-guard/src/index.ts +++ b/packages/guard/repeat-tool-guard/src/index.ts @@ -22,7 +22,7 @@ * exclude: [todo_write] # tool-name patterns transparent to the chain * ``` * - * Chain state is keyed per {@link AgentId} — the tool registry is a + * Chain state is keyed by the live agent object — the tool registry is a * context-level singleton whose waterfalls interleave every agent's calls, so * a shared counter would let one agent's repetition trip another's reminder. * State is in-memory only: a session resumed from persistence starts with a @@ -37,7 +37,7 @@ import type { Context } from 'cordis' import z from 'schemastery' -import type { AgentId, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent' +import type { Agent, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent' import type { MessageSource } from '@deepseek-ai/dsh-llm' import type { PostToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools' @@ -202,9 +202,7 @@ export function apply(ctx: Context, config: Config): void { throw new Error(`repeat-tool-guard: invalid argumentsPreviewChars ${argumentsPreviewChars} — must be an integer >= 1`) } - // TODO(agent-keyed-repeat-chain): key a WeakMap by the Agent itself; that - // removes the disposal-only status listener and cannot collide on id reuse. - const chains = new Map() + const chains = new WeakMap() /** Whether a tool participates in the chain (untracked calls are transparent: they neither count nor reset). */ function tracked(toolName: string): boolean { @@ -227,9 +225,9 @@ export function apply(ctx: Context, config: Config): void { if (!tracked(exec.name)) return undefined const canonical = canonicalize(exec.arguments) const key = JSON.stringify([exec.name, canonical]) - const chain = chains.get(exec.agent.id) + const chain = chains.get(exec.agent) const count = chain !== undefined && chain.key === key ? chain.count + 1 : 1 - chains.set(exec.agent.id, { key, count }) + chains.set(exec.agent, { key, count }) if (!thresholdSet.has(count)) return undefined const text = count === thresholds[0] ? GENTLE_REMINDER @@ -259,12 +257,7 @@ export function apply(ctx: Context, config: Config): void { // loop. Pure reset hook: always delegates (attaching nothing, vetoing // nothing). ctx.on('agent/prompt-submit', (agent, _content, _source, next): Promise => { - chains.delete(agent.id) + chains.delete(agent) return next() }) - - // Drop state when an agent goes away, bounding the map over harness lifetime. - ctx.on('agent/status', (agent, status) => { - if (status === 'disposed') chains.delete(agent.id) - }) } diff --git a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts index 565f1076b5..9538166fc4 100644 --- a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts +++ b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService, { CallId } from '@deepseek-ai/dsh-llm' -import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import * as RepeatToolGuard from '@deepseek-ai/dsh-repeat-tool-guard' import type { Config } from '@deepseek-ai/dsh-repeat-tool-guard' @@ -57,7 +58,7 @@ describe('threshold escalation', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -78,7 +79,7 @@ describe('threshold escalation', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -100,7 +101,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -124,7 +125,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -142,7 +143,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -163,7 +164,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -179,7 +180,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -195,7 +196,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -215,8 +216,8 @@ describe('chain semantics', () => { toolCallResponse('b3', 'probe', { q: 1 }), textResponse('done'), ])) - const agentA = ctx.agentLoop.create(AgentId('a'), { model: 'mock-a' }) - const agentB = ctx.agentLoop.create(AgentId('b'), { model: 'mock-b' }) + const agentA = ctx.agentLoop.create(SessionId('a'), { model: 'mock-a' }) + const agentB = ctx.agentLoop.create(SessionId('b'), { model: 'mock-b' }) agentA.send([{ type: 'text', text: 'go' }]) agentB.send([{ type: 'text', text: 'go' }]) await Promise.all([waitForIdle(ctx, agentA), waitForIdle(ctx, agentB)]) @@ -235,7 +236,7 @@ describe('chain semantics', () => { textResponse('turn two done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) agent.send([{ type: 'text', text: 'again' }]) @@ -256,14 +257,14 @@ describe('chain semantics', () => { // (the loop.spec pattern): a child plugin fiber owns `first`. let first!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - first = inner.agentLoop.create(AgentId('reused'), { model: 'mock' }) + first = inner.agentLoop.create(SessionId('reused'), { model: 'mock' }) }, { inject: ['agentLoop'] })) first.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, first) await fiber.dispose() await first.done - const second = ctx.agentLoop.create(AgentId('reused'), { model: 'mock' }) + const second = ctx.agentLoop.create(SessionId('reused'), { model: 'mock' }) second.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, second) @@ -279,7 +280,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -295,7 +296,7 @@ describe('chain semantics', () => { toolCallResponse('c1', 'probe', { q: 1 }), // if the direct call had counted, this would be #2 textResponse('done'), ])) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -317,7 +318,7 @@ describe('fold onto the downstream decision', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -348,7 +349,7 @@ describe('fold onto the downstream decision', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 2cbc995ce3..2f0a2666c9 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -5,10 +5,11 @@ import { join } from 'node:path' import { Context, type Fiber } from 'cordis' import Loader from '@cordisjs/plugin-loader' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' @@ -95,7 +96,7 @@ describe('hooks-claude bridge — UserPromptSubmit', () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(dir, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'do something' }]) await waitForIdle(ctx, agent) @@ -118,7 +119,7 @@ describe('hooks-claude bridge — UserPromptSubmit', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(dir, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -143,7 +144,7 @@ describe('hooks-claude bridge — PreToolUse', () => { const ctx = await harness(dir, adapter) let ran = false ctx.tools.register(defineTool({ name: 'danger', description: 'd', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'use danger' }]) await waitForIdle(ctx, agent) @@ -166,7 +167,7 @@ describe('hooks-claude bridge — PreToolUse', () => { const ctx = await harness(dir, adapter) let ran = false ctx.tools.register(defineTool({ name: 'safe', description: 's', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ran ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'use safe' }]) await waitForIdle(ctx, agent) @@ -188,7 +189,7 @@ describe('hooks-claude bridge — PostToolUse', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(dir, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'raw output' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -209,7 +210,7 @@ describe('hooks-claude bridge — PostToolUse', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(dir, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -233,7 +234,7 @@ describe('hooks-claude bridge — PostToolUse', () => { const ctx = await harness(dir, adapter) let ran = false ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -257,7 +258,7 @@ describe('hooks-claude bridge — SessionStart', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(dir, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // session-start fires async (detached .then → agent.inject); wait for the // injected context/message to actually land before sending, rather than a // fixed sleep that flakes under load. @@ -294,8 +295,8 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () => // Drive the observe-only lifecycle events directly (no real child needed — the // bridge just listens). No child agent is registered, so SubagentStart's // child lookup yields undefined and it simply runs the hook. - ctx.emit('subagent/start', { provider: 'inproc', id: AgentId('child-1') }) - ctx.emit('subagent/end', { provider: 'inproc', id: AgentId('child-1'), stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] }) + ctx.emit('subagent/start', { provider: 'inproc', id: SessionId('child-1') }) + ctx.emit('subagent/end', { provider: 'inproc', id: SessionId('child-1'), stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] }) // Both hooks run async (detached .then); poll for their marker files rather // than a fixed sleep that flakes under load. @@ -330,7 +331,7 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () => const { ctx, hooks } = await harnessWithFiber(dir, new MockAdapter([])) const warn = vi.fn() ctx.logger.warn = warn as never - ctx.emit('subagent/start', { provider: 'inproc', id: AgentId('child-1') }) + ctx.emit('subagent/start', { provider: 'inproc', id: SessionId('child-1') }) await waitFor(() => existsSync(marker)) const pid = Number(readFileSync(pidFile, 'utf8').trim()) await hooks.dispose() @@ -359,7 +360,7 @@ describe('hooks-claude bridge — load resilience', () => { await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(HooksClaude, { configPath: '/nonexistent/hooks.json' }) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // The turn ran normally — no hooks, no crash. @@ -385,7 +386,7 @@ describe('hooks-claude bridge — load resilience', () => { const fiber = await ctx.plugin(HooksClaude, { configPath: join(dir, 'hooks.json') }) await fiber.dispose() ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone diff --git a/packages/hooks/hooks-claude/tests/coverage.spec.ts b/packages/hooks/hooks-claude/tests/coverage.spec.ts index 7feb46df05..16c48aa149 100644 --- a/packages/hooks/hooks-claude/tests/coverage.spec.ts +++ b/packages/hooks/hooks-claude/tests/coverage.spec.ts @@ -4,10 +4,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' @@ -72,7 +73,7 @@ describe('hooks-claude coverage — config option arms + substitution + skip war const ctx = await harness(path, adapter, { pluginRoot: d, projectDir: d }) ctx.logger.warn = warn as never ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(existsSync(marker)).toBe(true) // substituted command ran @@ -88,7 +89,7 @@ describe('hooks-claude coverage — config option arms + substitution + skip war ctx.logger.warn = warn as never let sawArgs: unknown ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: { command: { type: 'string' } }, async execute(args) { sawArgs = args; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // updatedInput is NOT honored — the tool ran with the ORIGINAL args. @@ -104,7 +105,7 @@ describe('hooks-claude coverage — empty/no-op outcomes and no-agent paths', () const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('ran')]) const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // The prompt proceeded unchanged; no context/message injected. @@ -134,7 +135,7 @@ describe('hooks-claude coverage — empty/no-op outcomes and no-agent paths', () const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -159,7 +160,7 @@ describe('hooks-claude coverage — empty/no-op outcomes and no-agent paths', () const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 }) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -175,7 +176,7 @@ describe('hooks-claude coverage — Stop continuation + subagent inject/catch', const path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) @@ -192,7 +193,7 @@ describe('hooks-claude coverage — Stop continuation + subagent inject/catch', const path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // A second model request ran → the empty-reason block forced continuation. @@ -208,9 +209,9 @@ describe('hooks-claude coverage — Stop continuation + subagent inject/catch', const ctx = await harness(path, new MockAdapter([])) // Register a fake child agent under the id the event carries. const injected: string[] = [] - const child = { id: AgentId('child-x'), inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: { header: { id: 'child-x' } } } as unknown as Parameters[0] + const child = { id: SessionId('child-x'), inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: { header: { id: 'child-x' } } } as unknown as Parameters[0] ctx.agents.register(child) - ctx.emit('subagent/start', { provider: 'p', id: AgentId('child-x') }) + ctx.emit('subagent/start', { provider: 'p', id: SessionId('child-x') }) await waitFor(() => injected.includes('child guidance')) expect(injected).toContain('child guidance') }) @@ -224,9 +225,9 @@ describe('hooks-claude coverage — Stop continuation + subagent inject/catch', const path = hooks(d, { SubagentStart: [{ hooks: [{ type: 'command', command: s }] }] }) const ctx = await harness(path, new MockAdapter([])) const warn = vi.fn(); ctx.logger.warn = warn as never - const child = { id: AgentId('child-y'), inject: () => { throw new Error('inject boom') }, session: { header: { id: 'child-y' } } } as unknown as Parameters[0] + const child = { id: SessionId('child-y'), inject: () => { throw new Error('inject boom') }, session: { header: { id: 'child-y' } } } as unknown as Parameters[0] ctx.agents.register(child) - ctx.emit('subagent/start', { provider: 'p', id: AgentId('child-y') }) + ctx.emit('subagent/start', { provider: 'p', id: SessionId('child-y') }) await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SubagentStart hook failed'))) expect(warn).toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed')) }) @@ -240,7 +241,7 @@ describe('hooks-claude coverage — default reasons + sparse payloads', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'x' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -254,7 +255,7 @@ describe('hooks-claude coverage — default reasons + sparse payloads', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -270,7 +271,7 @@ describe('hooks-claude coverage — default reasons + sparse payloads', () => { const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) const path = hooks(d, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] }) const ctx = await harness(path, new MockAdapter([])) - ctx.emit('subagent/end', { provider: 'p', id: AgentId('child-z'), stopReason: 'completed' }) + ctx.emit('subagent/end', { provider: 'p', id: SessionId('child-z'), stopReason: 'completed' }) await waitFor(() => existsSync(marker)) expect(existsSync(marker)).toBe(true) }) @@ -283,7 +284,7 @@ describe('hooks-claude coverage — more default/sparse arms', () => { const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('no')]) const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const turnEnd = events(agent).findLast(e => e.type === 'turn/end') @@ -298,7 +299,7 @@ describe('hooks-claude coverage — more default/sparse arms', () => { const ctx = await harness(path, adapter) let ran = false ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // ask (no reason) → degrades to deny with the registry's generic message. @@ -313,7 +314,7 @@ describe('hooks-claude coverage — more default/sparse arms', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -342,7 +343,7 @@ describe('hooks-claude coverage — schema-bypass apply + unspawnable hook', () // the protocol lib's reference default, not a config knob). HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(existsSync(marker)).toBe(true) @@ -357,7 +358,7 @@ describe('hooks-claude coverage — schema-bypass apply + unspawnable hook', () const ctx = await harness(path, adapter) let ran = false ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(ran).toBe(true) @@ -372,7 +373,7 @@ describe('hooks-claude coverage — schema-bypass apply + unspawnable hook', () const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -393,7 +394,7 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => const ctx = await harness(path, adapter) let ran = false ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -410,7 +411,7 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -430,7 +431,7 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => const ctx = await harness(path, adapter) let ran = false ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(ran).toBe(true) // the mismatched deny was discarded → the tool ran @@ -448,7 +449,7 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => const ctx = await harness(path, adapter) // NB: no projectDir // The factory create() path honors meta.cwd (the plain agentLoop.create() does not). const { SessionId } = await import('@deepseek-ai/dsh-session') - const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { model: 'mock' } }) + const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { model: 'mock' } }) handle.agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, handle.agent as ReactLoopAgent) expect(events(handle.agent as ReactLoopAgent).some(e => e.type === 'context/message' @@ -466,7 +467,7 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(path, adapter) // A later listener that blocks every prompt (registered AFTER the bridge). - const { AgentId: AId } = await import('@deepseek-ai/dsh-agent') + const { SessionId: AId } = await import('@deepseek-ai/dsh-session') ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' })) const agent = ctx.agentLoop.create(AId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) @@ -492,7 +493,7 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => content: [{ type: 'text' as const, text: 'rewritten-prompt' }], additionalContext: { content: [{ type: 'text' as const, text: 'from-downstream' }], source: { kind: 'plugin' as const, plugin: 'policy' } }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const req = JSON.stringify(adapter.requests[0]!.messages) @@ -514,7 +515,7 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => const ctx = await harness(path, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'rewritten-result' }] })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -533,7 +534,7 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => const ctx = await harness(path, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -557,7 +558,7 @@ describe('hooks-claude coverage — executor reject + no-open-turn', () => { const bash = ctx.bash bash.run = (() => Promise.reject(new Error('executor down'))) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -573,7 +574,7 @@ describe('hooks-claude coverage — detached-listener catch handlers', () => { const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // Make inject throw, forcing the SessionStart .catch path. const original = agent.inject.bind(agent) let threw = false @@ -613,7 +614,7 @@ describe('hooks-claude coverage — hook runs in the session cwd, not the server ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const { SessionId } = await import('@deepseek-ai/dsh-session') - const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } }) + const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } }) handle.agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, handle.agent as ReactLoopAgent) @@ -649,7 +650,7 @@ describe('hooks-claude coverage — hook runs in the session cwd, not the server // Register a live child on its own session cwd; emit subagent/end with its id. const { SessionId } = await import('@deepseek-ai/dsh-session') - const childHandle = await ctx.agents.create({ agentId: AgentId('child-stop'), sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { model: 'mock' } }) + const childHandle = await ctx.agents.create({ sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { model: 'mock' } }) ctx.emit('subagent/end', { provider: 'inproc', id: childHandle.agent.id, stopReason: 'completed' }) await waitFor(() => existsSync(marker)) @@ -670,7 +671,7 @@ describe('hooks-claude coverage — systemMessage is warned, not surfaced', () = const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(path, adapter) const warn = vi.fn(); ctx.logger.warn = warn as never - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage')) @@ -691,7 +692,7 @@ describe('hooks-claude coverage — SessionStart timing is best-effort (no-wait) const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // Send immediately — do NOT wait for the session-start inject. agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index e0677306b0..4aa678c36e 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -5,10 +5,11 @@ import { join } from 'node:path' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' @@ -82,7 +83,7 @@ describe('hooks-codex bridge', () => { const ctx = await harness(dir, adapter) let ran = false ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'no' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'run ls' }]) await waitForIdle(ctx, agent) @@ -107,7 +108,7 @@ describe('hooks-codex bridge', () => { // Step 1 has no tool calls → would stop; the Stop hook forces step 2. const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer after goal')]) const ctx = await harness(dir, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -124,7 +125,7 @@ describe('hooks-codex bridge', () => { const adapter = new MockAdapter([textResponse('fine')]) const ctx = await harness(dir, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // Ran normally; the unknown event was dropped at parse. @@ -135,7 +136,7 @@ describe('hooks-codex bridge', () => { const dir = configDir() // no hooks.json written const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(dir, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) @@ -161,7 +162,7 @@ describe('hooks-codex bridge', () => { const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' }) await fiber.dispose() ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone @@ -190,7 +191,7 @@ describe('hooks-codex bridge', () => { ctx.llm.registerAdapter(['mock'], new MockAdapter([])) const warn = vi.fn() ctx.logger.warn = warn as never - ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) // fires agent/session-start + ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // fires agent/session-start await waitFor(() => existsSync(marker)) const pid = Number(readFileSync(pidFile, 'utf8').trim()) await fiber.dispose() diff --git a/packages/hooks/hooks-codex/tests/coverage.spec.ts b/packages/hooks/hooks-codex/tests/coverage.spec.ts index 774b308fa1..75b04d4c57 100644 --- a/packages/hooks/hooks-codex/tests/coverage.spec.ts +++ b/packages/hooks/hooks-codex/tests/coverage.spec.ts @@ -4,10 +4,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' @@ -52,7 +53,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) const adapter = new MockAdapter([textResponse('no')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) const te = events(agent).findLast(e => e.type === 'turn/end') @@ -64,7 +65,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"ctx-x"}}\'\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('ctx-x') }) @@ -78,7 +79,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) expect(events(agent).some(e => e.type === 'user/message')).toBe(false) @@ -96,7 +97,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { content: [{ type: 'text' as const, text: 'rewritten-prompt' }], additionalContext: { content: [{ type: 'text' as const, text: 'from-downstream' }], source: { kind: 'plugin' as const, plugin: 'policy' } }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const req = JSON.stringify(adapter.requests[0]!.messages) expect(req).toContain('from-bridge') @@ -111,7 +112,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'rewritten-result' }] })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true) @@ -125,7 +126,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.isError).toBe(true) @@ -138,7 +139,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"start-ctx"}}\'\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) await waitFor(() => events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('start-ctx')))) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) @@ -151,7 +152,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const r = events(agent).find(e => e.type === 'tool/result') expect(r?.type === 'tool/result' && r.data.isError).toBe(true) @@ -164,7 +165,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true) }) @@ -176,7 +177,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) // clean-exit hook allows; commandOf returned '' }) @@ -187,7 +188,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0) @@ -200,7 +201,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true) @@ -223,7 +224,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: 40 }) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…') @@ -246,7 +247,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { // Direct apply (schema bypass) → the `model ?? ''` fallback is exercised. HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(existsSync(marker)).toBe(true) expect(warn).toHaveBeenCalledWith(expect.stringContaining('async hook')) @@ -259,7 +260,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) }) @@ -273,7 +274,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', `#!/usr/bin/env bash\ntouch "${marker}"\nexit 0\n`) }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) await waitFor(() => existsSync(marker)) // the clean no-output hook has finished agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(events(agent).some(e => e.type === 'context/message')).toBe(false) @@ -285,7 +286,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const warn = vi.fn(); ctx.logger.warn = warn as never - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.inject = (() => { throw new Error('inject boom') }) await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SessionStart hook failed'))) expect(warn).toHaveBeenCalledWith(expect.stringContaining('SessionStart hook failed')) @@ -298,7 +299,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) }) @@ -311,7 +312,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) // matcher didn't match → no hook ran → tool proceeded expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) @@ -327,7 +328,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded @@ -340,7 +341,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const r = events(agent).find(e => e.type === 'tool/result') expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true) @@ -352,7 +353,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const r = events(agent).find(e => e.type === 'tool/result') expect(r?.type === 'tool/result' && r.data.isError).toBe(true) @@ -369,7 +370,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 7 }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'number' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_input: { command: string } } expect(payload.tool_input.command).toBe('') @@ -405,7 +406,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.bash.run = (() => Promise.reject(new Error('executor down'))) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false) @@ -419,7 +420,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { hooks(d, { Stop: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\nexit 2\n`) }] }] }) const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) // empty-reason block forced continuation expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('blocked by Stop hook') @@ -432,7 +433,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho "extra guidance from a plain hook"\nexit 0\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('extra guidance from a plain hook') }) @@ -448,7 +449,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', `#!/usr/bin/env bash\ntouch "${marker}"\necho "stale"\nexit 2\n`) }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) await waitFor(() => existsSync(marker)) // the exit-2 hook has finished expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('stale')))).toBe(false) @@ -462,7 +463,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'e.sh', '#!/usr/bin/env bash\necho "stale"\nexit 1\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) // exit 1 is non-blocking → the turn ran expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('stale') @@ -473,7 +474,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'ss.sh', '#!/usr/bin/env bash\necho "session preamble"\nexit 0\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) await waitFor(() => events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('session preamble')))) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) @@ -487,7 +488,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'j.sh', '#!/usr/bin/env bash\necho \'{"unrelated":"json"}\'\nexit 0\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('unrelated') }) @@ -502,7 +503,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'shell', { command: 'ls' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_name: string; tool_input: { command: string } } expect(payload.tool_name).toBe('shell') @@ -518,7 +519,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false ctx.tools.register(defineTool({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(false) // the matcher fired → the hook denied the tool expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.point === 'PreToolUse')).toBe(true) @@ -530,7 +531,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const warn = vi.fn(); ctx.logger.warn = warn as never - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage')) expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up') @@ -553,7 +554,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { ctx.llm.registerAdapter(['mock'], adapter) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const { SessionId } = await import('@deepseek-ai/dsh-session') - const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } }) + const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } }) handle.agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, handle.agent as ReactLoopAgent) expect(existsSync(marker)).toBe(true) diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 5222906a2d..1e82ef85b0 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -37,8 +37,8 @@ import { type SessionNotification, type StopReason, } from '@agentclientprotocol/sdk' -import { AgentId } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' import { buildChildEnv, disposeChildProcess, spawnFailure } from '@deepseek-ai/dsh-subagent-subprocess' @@ -190,7 +190,7 @@ function toError(value: unknown): Error { * @returns the ready run handle for the child subprocess. */ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Promise { - const id = AgentId(randomUUID()) + const id = SessionId(randomUUID()) if (request.signal.aborted) throw new Error('subagent request was aborted before the ACP child started') diff --git a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts index 8060a77fd4..d1b9065c60 100644 --- a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts +++ b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' @@ -37,7 +38,7 @@ async function setup(script: Script) { await ctx.plugin(Spawn, { providerName: 'spawn' }) await ctx.plugin(fork, { providerName: 'fork' }) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) - const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) + const parent = ctx.agentLoop.create(SessionId('parent'), { model: 'mock' }) return { ctx, parent } } diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index e089cebe5f..360098e369 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -2,10 +2,11 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' @@ -43,7 +44,7 @@ async function setup(script: Script) { await ctx.plugin(SubagentService) await ctx.plugin(fork, { providerName: 'fork' }) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) - const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) + const parent = ctx.agentLoop.create(SessionId('parent'), { model: 'mock' }) return { ctx, parent } } diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index f6de7200cf..965d9c8e78 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -9,7 +9,7 @@ import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' -import { AgentId, type Agent, type AgentOptions } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent' @@ -104,7 +104,7 @@ export async function startInProcessRun( throw new SubagentDepthError(childDepth, request.maxDepth) } - const childId = AgentId(randomUUID()) + const childId = SessionId(randomUUID()) const seedLength = options.seed?.length ?? 0 const parentHeader = parent.session.header const parentModel = parent.options.model @@ -127,8 +127,7 @@ export async function startInProcessRun( const flags = { cancelled: false } const handle = await parent.ctx.agents.create({ - agentId: childId, - sessionId: SessionId(randomUUID()), + sessionId: childId, meta: { ...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {}, parentSession: parentHeader.id, diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 3e23123622..93da5b1d51 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService, { CallId, type ContentBlock, type GenerateOptions } from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import type { ContinuationDecision } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' @@ -68,7 +69,7 @@ async function setup(script: Script, options: SetupOptions = {}) { start: (request: SubagentStartRequest) => startInProcessRun(request, {}), }) ctx.llm.registerAdapter(['mock'], adapter) - const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) + const parent = ctx.agentLoop.create(SessionId('parent'), { model: 'mock' }) return { ctx, parent, adapter, disposeProvider } } @@ -333,7 +334,7 @@ describe('in-process structured output', () => { await expect(ctx.subagents.start('spawn', structuredRequest(parent, { outputSchema: { type: 'object', oneOf: [] } as unknown as StructuredOutputSchema, }))).rejects.toThrow(/unsupported output schema/) - expect(ctx.agents.get(AgentId('parent'))).toBeDefined() + expect(ctx.agents.get(SessionId('parent'))).toBeDefined() }) it('a schema carrying non-JSON values fails as OutputSchemaError at the validation boundary', async () => { diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 0005246523..aa0f946c89 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' + import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService from '@deepseek-ai/dsh-subagent' @@ -24,7 +25,7 @@ async function setup(script: Script) { await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) - const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) + const parent = ctx.agentLoop.create(SessionId('parent'), { model: 'mock' }) return { ctx, parent } } diff --git a/packages/subagent/subagent-spawn/tests/spawn.e2e.ts b/packages/subagent/subagent-spawn/tests/spawn.e2e.ts index daa032199e..b156f951ed 100644 --- a/packages/subagent/subagent-spawn/tests/spawn.e2e.ts +++ b/packages/subagent/subagent-spawn/tests/spawn.e2e.ts @@ -3,8 +3,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' import { spawnHarness, waitForIdle } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * With-key smoke for the in-process spawn backend: a REAL parent agent delegates @@ -29,7 +29,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('spawn backend with-key smoke', ( it('a parent delegates to a child that writes a file on disk', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-spawn-e2e-')) ctx = await spawnHarness(workdir) - const parent = ctx.agentLoop.create(AgentId('e2e-parent'), { model: 'deepseek-v4-flash' }) + const parent = ctx.agentLoop.create(SessionId('e2e-parent'), { model: 'deepseek-v4-flash' }) parent.send([{ type: 'text', text: 'Use the subagent tool to delegate this exact task: "Use the bash tool to write the text ' diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 9e4e489882..06aa40ca9f 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -5,7 +5,8 @@ import LlmService 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, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' @@ -36,7 +37,7 @@ async function setup(script: Script) { await ctx.plugin(SubagentService) await ctx.plugin(spawn, { providerName: 'spawn' }) ctx.llm.registerAdapter(['mock'], adapter) - const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) + const parent = ctx.agentLoop.create(SessionId('parent'), { model: 'mock' }) return { ctx, parent, adapter } } @@ -237,7 +238,6 @@ describe('dsh-subagent-spawn', () => { const { ctx } = await setup([textResponse('x')]) // A parent WITH a cwd (config agents have none, so create one explicitly). const parentHandle = await ctx.agents.create({ - agentId: AgentId('cwd-parent'), sessionId: SessionId('cwd-parent-session'), meta: { cwd: '/tmp/parent-workspace' }, agentOptions: { model: 'mock' }, @@ -254,7 +254,6 @@ describe('dsh-subagent-spawn', () => { const { ctx } = await setup([textResponse('explicit model child')]) // A parent with NO model (its own turns would need one supplied per-request). const parentHandle = await ctx.agents.create({ - agentId: AgentId('modelless-parent'), sessionId: SessionId('modelless-parent-session'), agentOptions: {}, }) @@ -318,7 +317,7 @@ describe('dsh-subagent-spawn', () => { await ctx.plugin(SubagentService) const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) ctx.llm.registerAdapter(['mock'], adapter) - const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) + const parent = ctx.agentLoop.create(SessionId('parent'), { model: 'mock' }) const controller = new AbortController() const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'q' }], @@ -349,7 +348,7 @@ describe('dsh-subagent-spawn', () => { await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) - const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) + const parent = ctx.agentLoop.create(SessionId('parent'), { model: 'mock' }) const parentEffects = parent.ctx.fiber.getEffects().length const published: string[] = [] ctx.on('session/created', () => void published.push('session/created')) @@ -442,7 +441,6 @@ describe('dsh-subagent-spawn', () => { const { ctx } = await setup([]) // A handle-owned parent we can dispose (config agents dispose with the loop fiber). const parentHandle = await ctx.agents.create({ - agentId: AgentId('doomed-parent'), sessionId: SessionId('doomed-s'), agentOptions: { model: 'mock' }, }) @@ -465,7 +463,6 @@ describe('dsh-subagent-spawn', () => { it('parent disposal during the child setup transaction prevents every publication notification', async () => { const { ctx } = await setup([]) const parentHandle = await ctx.agents.create({ - agentId: AgentId('setup-race-parent'), sessionId: SessionId('setup-race-parent-session'), agentOptions: { model: 'mock' }, }) diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 4635de9697..e885c16469 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -18,7 +18,8 @@ import type { Scoped } from '@deepseek-ai/dsh-scope' import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { Agent, AgentId } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { SessionId } from '@deepseek-ai/dsh-session' import type { SubagentCapabilities, SubagentProvider, @@ -96,7 +97,7 @@ export interface SubagentRunInfo { /** The provider that established the run. */ readonly provider: string /** The child agent's id. */ - readonly id: AgentId + readonly id: SessionId } /** Observe-only outcome detail for a settled subagent run. */ @@ -104,7 +105,7 @@ export interface SubagentRunEndInfo { /** The provider that ran it. */ readonly provider: string /** The child agent's id. */ - readonly id: AgentId + readonly id: SessionId /** The terminal stop reason. */ readonly stopReason: SubagentResult['stopReason'] /** The child's final assistant output, absent on infrastructure rejection. */ diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 3bdc78569a..8d360b1182 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -6,8 +6,9 @@ * @module @deepseek-ai/dsh-subagent/types */ -import type { Agent, AgentId, AgentOptions } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SessionId } from '@deepseek-ai/dsh-session' import type { StructuredOutputSchema, ToolRestriction } from '@deepseek-ai/dsh-tools' /** @@ -147,7 +148,7 @@ export interface SubagentResult { */ export interface SubagentRun { /** The child agent's id (local in-process runs are already published in `ctx.agents`; remote transports need not publish locally). */ - readonly id: AgentId + readonly id: SessionId /** * Resolves with the child's terminal {@link SubagentResult} when the run * settles. Does NOT reject on a child-level failure — a model/transport diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 3d10b425ed..ad7dbaafff 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import { type Agent } from '@deepseek-ai/dsh-agent' + import { HarnessError } from '@deepseek-ai/dsh-llm' import { carrierKeyOf } from '@deepseek-ai/dsh-scope' import SubagentService, { @@ -12,9 +13,10 @@ import SubagentService, { type SubagentRun, type SubagentStartRequest, } from '@deepseek-ai/dsh-subagent' +import { SessionId } from '@deepseek-ai/dsh-session' function fakeParent(id = 'parent-1'): Agent { - return { id: AgentId(id) } as unknown as Agent + return { id: SessionId(id) } as unknown as Agent } const ALL_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true } @@ -45,7 +47,7 @@ class StubProvider implements SubagentProvider { async start(request: SubagentStartRequest): Promise { this.startCount += 1 return { - id: AgentId(`child:${this.name}:${request.parent.id}`), + id: SessionId(`child:${this.name}:${request.parent.id}`), result: Promise.resolve(this.outcome), async dispose() {}, } @@ -141,7 +143,7 @@ describe('SubagentService', () => { const starting = subagents.start('deferred', baseRequest({ parent })) await Promise.resolve() expect(events).toEqual([]) - ready.resolve({ id: AgentId('child'), result: result.promise, async dispose() {} }) + ready.resolve({ id: SessionId('child'), result: result.promise, async dispose() {} }) const run = await starting expect(events).toEqual(['start']) result.resolve({ output: [{ type: 'text', text: 'answer' }], stopReason: 'completed' }) @@ -190,7 +192,7 @@ describe('SubagentService', () => { capabilities: NO_CAPS, inheritsParentContext: false, async start() { - return { id: AgentId('infra-child'), result: failure.promise, async dispose() {} } + return { id: SessionId('infra-child'), result: failure.promise, async dispose() {} } }, }) const failedRun = await subagents.start('infra', baseRequest()) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 5830513cf4..79df080657 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -4,10 +4,12 @@ import Loader from '@cordisjs/plugin-loader' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import { type Agent } from '@deepseek-ai/dsh-agent' + import SubagentService from '@deepseek-ai/dsh-subagent' import * as mock from '@deepseek-ai/dsh-subagent-mock' import * as tool from '../src/index.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * Drives the REAL plugin body: mounts `dsh-tool-subagent` on a real @@ -20,7 +22,7 @@ import * as tool from '../src/index.ts' /** A minimal parent Agent — the tool reads `agent.id` for `parent`. */ function fakeAgent(id = 'parent-1'): Agent { - return { id: AgentId(id) } as unknown as Agent + return { id: SessionId(id) } as unknown as Agent } async function setup(toolConfig: tool.Config, mockConfig: Partial = {}) { @@ -114,7 +116,7 @@ describe('dsh-tool-subagent', () => { capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => ({ - id: AgentId('weird-child'), + id: SessionId('weird-child'), result: Promise.resolve({ output: [{ type: 'text', text: 'partial' }], stopReason: 'frobnicated' as never }), dispose: async () => {}, }), @@ -141,7 +143,7 @@ describe('dsh-tool-subagent', () => { start: async (request) => { seen = request return { - id: AgentId('capture-child'), + id: SessionId('capture-child'), result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => {}, } @@ -170,7 +172,7 @@ describe('dsh-tool-subagent', () => { start: async (request) => { seen = request return { - id: AgentId('bare-child'), + id: SessionId('bare-child'), result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => {}, } @@ -297,7 +299,7 @@ describe('dsh-tool-subagent', () => { capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => ({ - id: AgentId('spy-child'), + id: SessionId('spy-child'), result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => void disposed(), }), @@ -319,7 +321,7 @@ describe('dsh-tool-subagent', () => { capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => ({ - id: AgentId('spy-child'), + id: SessionId('spy-child'), result: Promise.resolve({ output: [], stopReason: 'error' as const }), dispose: async () => void disposed(), }), @@ -350,7 +352,7 @@ describe('dsh-tool-subagent', () => { resolveResult({ output: [], stopReason: 'aborted' }) }, { once: true }) return { - id: AgentId('spy-child'), + id: SessionId('spy-child'), result, dispose: async () => {}, } @@ -442,7 +444,7 @@ describe('dsh-tool-subagent', () => { start: async (request) => { seen = request return { - id: AgentId('capture2-child'), + id: SessionId('capture2-child'), result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => {}, } @@ -499,7 +501,7 @@ describe('dsh-tool-subagent', () => { start: async (request) => { seen = request return { - id: AgentId('capture3-child'), + id: SessionId('capture3-child'), result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => {}, } @@ -528,7 +530,7 @@ describe('dsh-tool-subagent', () => { start: async (request) => { seen = request return { - id: AgentId('capture4-child'), + id: SessionId('capture4-child'), result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => {}, } diff --git a/packages/support/subagent-mock/src/index.ts b/packages/support/subagent-mock/src/index.ts index abc1a6c5e9..bf7bcbe2fb 100644 --- a/packages/support/subagent-mock/src/index.ts +++ b/packages/support/subagent-mock/src/index.ts @@ -13,8 +13,8 @@ import type { Context } from 'cordis' import z from 'schemastery' -import { AgentId } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' import type { SubagentCapabilities, SubagentProvider, @@ -65,7 +65,7 @@ class MockSubagentProvider implements SubagentProvider { // A deterministic child id derived from the parent — no clock/random (both // banned in deterministic paths here, and unnecessary for a scripted run). - const id = AgentId(`mock-subagent:${this.name}:${request.parent.id}`) + const id = SessionId(`mock-subagent:${this.name}:${request.parent.id}`) const resultFor = (): SubagentResult => ({ output, diff --git a/packages/support/subagent-mock/tests/subagent-mock.spec.ts b/packages/support/subagent-mock/tests/subagent-mock.spec.ts index 7fd93c62bc..b5e7180782 100644 --- a/packages/support/subagent-mock/tests/subagent-mock.spec.ts +++ b/packages/support/subagent-mock/tests/subagent-mock.spec.ts @@ -1,13 +1,15 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import { type Agent } from '@deepseek-ai/dsh-agent' + import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import * as mock from '../src/index.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** A minimal parent — the mock provider only reads `parent.id`. */ function fakeParent(id = 'parent-1'): Agent { - return { id: AgentId(id) } as unknown as Agent + return { id: SessionId(id) } as unknown as Agent } function baseRequest(over: Partial = {}): SubagentStartRequest { diff --git a/packages/todo/tool-todo/tests/integration.spec.ts b/packages/todo/tool-todo/tests/integration.spec.ts index 739367a699..8376a70425 100644 --- a/packages/todo/tool-todo/tests/integration.spec.ts +++ b/packages/todo/tool-todo/tests/integration.spec.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -64,7 +65,7 @@ describe('todo_write tool through the agent loop', () => { textResponse('Plan recorded.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('it-todo'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('it-todo'), { model: 'mock' }) agent.send([{ type: 'text', text: 'plan a two-step task' }]) await waitForIdle(ctx, agent) @@ -92,7 +93,7 @@ describe('todo_write tool through the agent loop', () => { textResponse('Done planning.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('it-todo-2'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('it-todo-2'), { model: 'mock' }) agent.send([{ type: 'text', text: 'plan then update' }]) await waitForIdle(ctx, agent) diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts index 86e8814633..b426b6760c 100644 --- a/packages/todo/tool-todo/tests/tool-todo.spec.ts +++ b/packages/todo/tool-todo/tests/tool-todo.spec.ts @@ -6,7 +6,8 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import { Session, SessionId } from '@deepseek-ai/dsh-session' import type { TodoItem } from '@deepseek-ai/dsh-session' -import { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import { type Agent } from '@deepseek-ai/dsh-agent' + import * as tool from '../src/index.ts' /** @@ -20,7 +21,7 @@ import * as tool from '../src/index.ts' /** A parent Agent backed by a real Session — the tool reads `agent.session`. */ function agentWithSession(id = 'parent-1'): Agent & { session: Session } { const session = new Session(SessionId(id)) - return { id: AgentId(id), session } as unknown as Agent & { session: Session } + return { id: SessionId(id), session } as unknown as Agent & { session: Session } } async function setup(): Promise { diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index a3fdc38f97..e795c7e177 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -72,7 +72,6 @@ import { import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { assertNever, CallId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' -import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-bash' import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' @@ -686,7 +685,6 @@ export function apply(ctx: Context, config: AcpConfig): void { validateMcpServers(params) const sessionId = SessionId(randomUUID()) const handle = await agents.create({ - agentId: AgentId(sessionId), sessionId, meta: { cwd: params.cwd }, agentOptions: agentOptions(config), @@ -757,7 +755,6 @@ export function apply(ctx: Context, config: AcpConfig): void { } } const handle = await agents.resume({ - agentId: AgentId(sessionId), resumeSessionId: sessionId, agentOptions: agentOptions(config), }) diff --git a/packages/ui/acp/tests/approval.spec.ts b/packages/ui/acp/tests/approval.spec.ts index ed035aaf80..36846ac728 100644 --- a/packages/ui/acp/tests/approval.spec.ts +++ b/packages/ui/acp/tests/approval.spec.ts @@ -4,9 +4,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { CallId } from '@deepseek-ai/dsh-llm' -import { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import { type Agent } from '@deepseek-ai/dsh-agent' + import ApprovalService, { type ApprovalRequest } from '@deepseek-ai/dsh-user-approval' import { makeBridgeHarness, type BridgeHarness } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * The bridge's `approval/request` answerer: an ask for an agent the bridge @@ -31,7 +33,7 @@ describe('acp bridge — approval answerer', () => { ): Promise<{ agent: Agent; request: ApprovalRequest }> { await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = h.ctx.agents.get(AgentId(sessionId)) + const agent = h.ctx.agents.get(SessionId(sessionId)) if (agent === undefined) throw new Error('newSession created no agent') // In production an ask always fires mid-turn (tool execution); open one so // request()'s turn-enclosure precondition holds for the direct drive below. diff --git a/packages/ui/acp/tests/bridge.spec.ts b/packages/ui/acp/tests/bridge.spec.ts index be05a09644..7e780348ba 100644 --- a/packages/ui/acp/tests/bridge.spec.ts +++ b/packages/ui/acp/tests/bridge.spec.ts @@ -3,8 +3,8 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' -import { AgentId } from '@deepseek-ai/dsh-agent' import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * End-to-end bridge specs over an in-memory transport: a real @@ -98,7 +98,7 @@ describe('acp bridge', () => { required: [], }, }) - const toolResult = harness.ctx.agents.get(AgentId(sessionId))!.session.events.find(event => event.type === 'tool/result') + const toolResult = harness.ctx.agents.get(SessionId(sessionId))!.session.events.find(event => event.type === 'tool/result') const toolResultBlock = toolResult?.type === 'tool/result' ? toolResult.data.content[0] : undefined const toolResultText = toolResultBlock?.type === 'text' ? toolResultBlock.text : undefined expect(toolResultText).toBe('{"answers":[{"id":"language","selected":["Python"]}]}') @@ -127,7 +127,7 @@ describe('acp bridge', () => { required: ['custom'], }, }) - const toolResult = harness.ctx.agents.get(AgentId(sessionId))!.session.events.find(event => event.type === 'tool/result') + const toolResult = harness.ctx.agents.get(SessionId(sessionId))!.session.events.find(event => event.type === 'tool/result') expect(JSON.stringify(toolResult)).toContain('apollo') }) @@ -136,7 +136,7 @@ describe('acp bridge', () => { harness.onElicitation = () => ({ action: 'accept', content: { custom: 'Use Zig' } }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! const result = await harness.ctx.userInteraction.ask({ agent, @@ -167,7 +167,7 @@ describe('acp bridge', () => { harness.onElicitation = () => ({ action: 'accept', content: { choice: 'TypeScript', custom: 'Use Zig' } }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! await expect(harness.ctx.userInteraction.ask({ agent, @@ -184,7 +184,7 @@ describe('acp bridge', () => { harness.onElicitation = () => ({ action: 'accept', content: { choice: ['Tests', 'Docs'] } }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! await expect(harness.ctx.userInteraction.ask({ agent, @@ -201,7 +201,7 @@ describe('acp bridge', () => { harness = await makeBridgeHarness({ storageDir, withAskUser: true }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! await expect(harness.ctx.userInteraction.ask({ questions: [{ id: 'x', question: 'No agent?' }] })) .rejects.toMatchObject({ name: 'UserInteractionError', code: 'NO_AGENT' }) @@ -225,7 +225,7 @@ describe('acp bridge', () => { harness = await makeBridgeHarness({ storageDir, withAskUser: true }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! const alreadyAborted = new AbortController() alreadyAborted.abort() @@ -265,8 +265,8 @@ describe('acp bridge', () => { expect(b.sessionId).toBeTruthy() expect(a.sessionId).not.toBe(b.sessionId) // Both agents are live and independently registered. - expect(harness.ctx.agents.get(AgentId(a.sessionId))).toBeDefined() - expect(harness.ctx.agents.get(AgentId(b.sessionId))).toBeDefined() + expect(harness.ctx.agents.get(SessionId(a.sessionId))).toBeDefined() + expect(harness.ctx.agents.get(SessionId(b.sessionId))).toBeDefined() }) it('rejects a non-absolute cwd but accepts any absolute cwd (per-session workspace)', async () => { @@ -281,7 +281,7 @@ describe('acp bridge', () => { const res = await harness.client.newSession({ cwd: '/tmp', mcpServers: [] }) expect(res.sessionId).toBeTruthy() // The session header records that cwd, so its bash tools run there. - expect(harness.ctx.agents.get(AgentId(res.sessionId))!.session.header.cwd).toBe('/tmp') + expect(harness.ctx.agents.get(SessionId(res.sessionId))!.session.header.cwd).toBe('/tmp') }) it('rejects non-empty additionalDirectories', async () => { @@ -321,7 +321,7 @@ describe('acp bridge', () => { ], }) expect(result.stopReason).toBe('end_turn') - const user = harness.ctx.agents.get(AgentId(sessionId))!.session.events.find(event => event.type === 'user/message') + const user = harness.ctx.agents.get(SessionId(sessionId))!.session.events.find(event => event.type === 'user/message') expect(JSON.stringify(user)).toContain('resource_link') }) diff --git a/packages/ui/acp/tests/dispose.spec.ts b/packages/ui/acp/tests/dispose.spec.ts index f02a7b0649..b5d98c0881 100644 --- a/packages/ui/acp/tests/dispose.spec.ts +++ b/packages/ui/acp/tests/dispose.spec.ts @@ -4,7 +4,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { SessionId } from '@deepseek-ai/dsh-session' -import { AgentId } from '@deepseek-ai/dsh-agent' import { makeBridgeHarness, textResponse } from './harness.ts' describe('acp bridge — disposal & HMR safety', () => { @@ -17,7 +16,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(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! // Start a prompt that hangs in the model stream. const promptDone = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) @@ -62,10 +61,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(AgentId(sessionId))).toBeDefined() + expect(harness.ctx.agents.get(SessionId(sessionId))).toBeDefined() await harness.acpFiber.dispose() // tear down ONLY the bridge - expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined() + expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() await harness.dispose() }) @@ -92,7 +91,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(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! // Start a prompt that hangs in the model stream. The prompt RPC will never // return (its transport is severed), so do not await it. void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {}) @@ -115,7 +114,7 @@ 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(AgentId(sessionId))).toBeUndefined() + expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined() await harness.dispose() }) @@ -128,7 +127,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(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {}) await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') @@ -145,7 +144,7 @@ describe('acp bridge — disposal & HMR safety', () => { const harness = await makeBridgeHarness({ storageDir, script: [] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const session = harness.ctx.agents.get(AgentId(sessionId))!.session + const session = harness.ctx.agents.get(SessionId(sessionId))!.session await harness.ctx.fiber.dispose() const before = harness.updates.length @@ -169,12 +168,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(AgentId(sessionId))!.session.events.length + const liveEvents = harness.ctx.agents.get(SessionId(sessionId))!.session.events.length expect(liveEvents).toBeGreaterThan(0) // Tear down JUST the bridge (the AgentHandle dispose runs to quiescence). await harness.acpFiber.dispose() - expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined() + expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() // Re-load the session from disk: every live event (incl. the closing // turn/end) was flushed before the session was detached. @@ -201,7 +200,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(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {}) await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') @@ -211,7 +210,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(AgentId(sessionId))).toBeUndefined() + expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() // The loop's own `turn/end {disposed}` is on disk (re-load: the world, not // self-report) — NOT a crash-recovery `interrupted` substitute. @@ -230,21 +229,21 @@ describe('acp bridge — disposal & HMR safety', () => { // queryable, with its session still in the store. const harness = await makeBridgeHarness({ storageDir, script: [] }) const handleA = await harness.ctx.agents.create({ - agentId: AgentId('sib-a'), sessionId: SessionId('sib-a'), agentOptions: { model: 'mock' }, + sessionId: SessionId('sib-a'), agentOptions: { model: 'mock' }, }) const handleB = await harness.ctx.agents.create({ - agentId: AgentId('sib-b'), sessionId: SessionId('sib-b'), agentOptions: { model: 'mock' }, + sessionId: SessionId('sib-b'), agentOptions: { model: 'mock' }, }) - expect(harness.ctx.agents.get(AgentId('sib-a'))).toBe(handleA.agent) - expect(harness.ctx.agents.get(AgentId('sib-b'))).toBe(handleB.agent) + expect(harness.ctx.agents.get(SessionId('sib-a'))).toBe(handleA.agent) + expect(harness.ctx.agents.get(SessionId('sib-b'))).toBe(handleB.agent) await handleA.dispose() // A is gone — unregistered AND its session removed from the store. - expect(harness.ctx.agents.get(AgentId('sib-a'))).toBeUndefined() + expect(harness.ctx.agents.get(SessionId('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(AgentId('sib-b'))).toBe(handleB.agent) + expect(harness.ctx.agents.get(SessionId('sib-b'))).toBe(handleB.agent) expect(harness.ctx.sessions.get(SessionId('sib-b'))).toBeDefined() expect(handleB.agent.status).not.toBe('disposed') await harness.dispose() @@ -262,7 +261,7 @@ 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 = await harness.ctx.agents.create({ - agentId: AgentId('guard-a'), sessionId: SessionId('guard-a'), agentOptions: { model: 'mock' }, + sessionId: SessionId('guard-a'), agentOptions: { model: 'mock' }, }) handle.agent.send([{ type: 'text', text: 'go' }]) await handle.agent.whenIdle() @@ -270,7 +269,7 @@ describe('acp bridge — disposal & HMR safety', () => { // Dispose: the throwing listener must NOT break the chain before detach. await handle.dispose() - expect(harness.ctx.agents.get(AgentId('guard-a'))).toBeUndefined() + expect(harness.ctx.agents.get(SessionId('guard-a'))).toBeUndefined() expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeUndefined() // detach still ran await harness.dispose() }) @@ -283,7 +282,7 @@ describe('acp bridge — disposal & HMR safety', () => { // observe the same quiescence boundary. const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) const handle = await harness.ctx.agents.create({ - agentId: AgentId('conc-a'), sessionId: SessionId('conc-a'), agentOptions: { model: 'mock' }, + 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 @@ -313,7 +312,7 @@ 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(AgentId('conc-a'))).toBeUndefined() + expect(harness.ctx.agents.get(SessionId('conc-a'))).toBeUndefined() expect(harness.ctx.sessions.get(SessionId('conc-a'))).toBeUndefined() await harness.dispose() }) diff --git a/packages/ui/acp/tests/edges.spec.ts b/packages/ui/acp/tests/edges.spec.ts index e86fb9fc94..329016a69e 100644 --- a/packages/ui/acp/tests/edges.spec.ts +++ b/packages/ui/acp/tests/edges.spec.ts @@ -3,7 +3,6 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' -import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' @@ -27,7 +26,7 @@ describe('acp bridge — demux & config edges', () => { await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) const before = harness.updates.length - const { agent: foreign } = await harness.ctx.agents.create({ agentId: AgentId('foreign'), sessionId: SessionId('foreign-session'), agentOptions: { model: 'mock' } }) + const { agent: foreign } = await harness.ctx.agents.create({ sessionId: SessionId('foreign-session'), agentOptions: { model: 'mock' } }) foreign.send([{ type: 'text', text: 'hi' }]) await foreign.whenIdle() await new Promise(r => setTimeout(r, 10)) diff --git a/packages/ui/acp/tests/load.spec.ts b/packages/ui/acp/tests/load.spec.ts index 2fbc95b3d9..efc9399c75 100644 --- a/packages/ui/acp/tests/load.spec.ts +++ b/packages/ui/acp/tests/load.spec.ts @@ -4,7 +4,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' -import { AgentId } from '@deepseek-ai/dsh-agent' import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts' /** Concatenate the text of all agent_message_chunk updates. */ @@ -194,7 +193,7 @@ describe('acp bridge — session/load replay', () => { release() // resume() finishes AFTER teardown expect(await loadResult).toBe('rejected') // No live agent was installed for the closed connection. - expect(loader.ctx.agents.get(AgentId(sessionId))).toBeUndefined() + expect(loader.ctx.agents.get(SessionId(sessionId))).toBeUndefined() }) it('rejects load when the requested cwd does not match the persisted session cwd', async () => { @@ -215,11 +214,11 @@ describe('acp bridge — session/load replay', () => { await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) await expect(loader.client.loadSession({ sessionId: 'elsewhere', cwd: process.cwd(), mcpServers: [] })) .rejects.toThrow(/cwd mismatch/) - expect(loader.ctx.agents.get(AgentId('elsewhere'))).toBeUndefined() + expect(loader.ctx.agents.get(SessionId('elsewhere'))).toBeUndefined() const res = await loader.client.loadSession({ sessionId: 'elsewhere', cwd: `${otherCwd}/.`, mcpServers: [] }) expect(res).toBeDefined() - expect(loader.ctx.agents.get(AgentId('elsewhere'))!.session.header.cwd).toBe(otherCwd) + expect(loader.ctx.agents.get(SessionId('elsewhere'))!.session.header.cwd).toBe(otherCwd) }) it('rejects load for a non-absolute cwd (still required to be absolute)', async () => { @@ -254,7 +253,7 @@ describe('acp bridge — session/load replay', () => { // Rejected BEFORE resume (metadata-only check) — no agent was registered, so // the id is not wedged: a later attempt hits the same clean rejection, not a // duplicate-registration error. - expect(loader.ctx.agents.get(AgentId('legacy'))).toBeUndefined() + expect(loader.ctx.agents.get(SessionId('legacy'))).toBeUndefined() await expect(loader.client.loadSession({ sessionId: 'legacy', cwd: process.cwd(), mcpServers: [] })) .rejects.toThrow(/no absolute persisted cwd/) }) diff --git a/packages/ui/acp/tests/multi-session.spec.ts b/packages/ui/acp/tests/multi-session.spec.ts index ca11934046..0881fe4199 100644 --- a/packages/ui/acp/tests/multi-session.spec.ts +++ b/packages/ui/acp/tests/multi-session.spec.ts @@ -3,8 +3,8 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' -import { AgentId } from '@deepseek-ai/dsh-agent' import { makeBridgeHarness, textResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** Text of the agent_message_chunk updates scoped to one session id. */ function messageTextFor(updates: { sessionId?: string; update: CapturedUpdate }[], sessionId: string): string { @@ -102,8 +102,8 @@ describe('acp bridge — RFC 011 multi-session isolation', () => { await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId const b = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId - const agentA = harness.ctx.agents.get(AgentId(a))! - const agentB = harness.ctx.agents.get(AgentId(b))! + const agentA = harness.ctx.agents.get(SessionId(a))! + const agentB = harness.ctx.agents.get(SessionId(b))! // Wait deterministically for BOTH agents to enter `running` (not a fixed // sleep — agent startup latency is unbounded on a loaded worker). diff --git a/packages/ui/acp/tests/turns.spec.ts b/packages/ui/acp/tests/turns.spec.ts index f559ca36d9..000b80336d 100644 --- a/packages/ui/acp/tests/turns.spec.ts +++ b/packages/ui/acp/tests/turns.spec.ts @@ -3,7 +3,6 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { defineTool } from '@deepseek-ai/dsh-tools' -import { AgentId } from '@deepseek-ai/dsh-agent' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { errorResponse, @@ -13,6 +12,7 @@ import { toolCallResponse, type BridgeHarness, } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** Boilerplate: initialize + create one session, returning its id. */ async function newSession(h: BridgeHarness, clientCapabilities: Record = {}): Promise { @@ -279,7 +279,7 @@ describe('acp bridge — turn outcomes', () => { // OWN turn with the real model answer. harness = await makeBridgeHarness({ storageDir, script: [textResponse('real answer')] }) const sessionId = await newSession(harness) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! // On the queued prompt, synchronously inject a one-shot context turn (idle // inject writes turn/start{injection} → context/message → turn/end). Fire // once so it lands between install and the prompt turn. @@ -335,7 +335,7 @@ describe('acp bridge — turn outcomes', () => { await harness.client.cancel({ sessionId }) const res = await promptDone expect(res.stopReason).toBe('cancelled') - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! await agent.whenIdle() // At most ONE turn ran (the cancelled one) — the cancel cleared the queue, so // no second turn was batched or leaked. (A best-effort abort that left queued diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index 86af6645f3..4dbfeb0fa2 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -16,7 +16,6 @@ import type { Context } from 'cordis' import { resolve } from 'node:path' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { AgentHandle } from '@deepseek-ai/dsh-agent' -import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session' import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -255,7 +254,6 @@ export class HarnessSdkServer { private async createSession(sessionId: string): Promise { const handle = await this.ctx.agents.create({ - agentId: AgentId(sessionId), sessionId: SessionId(sessionId), meta: { cwd: this.cwd }, agentOptions: { model: this.model }, diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 1cde550f2e..9260a59aae 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -5,7 +5,8 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { AgentId, type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent' +import { type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent' + import { SessionId } from '@deepseek-ai/dsh-session' import * as agentCore from '@deepseek-ai/dsh-agent-core' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' @@ -135,7 +136,6 @@ describe('HarnessSdkServer', () => { expect(llmServer.requests).toHaveLength(2) const orphanHandle = await ctx.agents.create({ - agentId: AgentId('orphan-agent'), sessionId: SessionId('orphan-session'), meta: { cwd: storageDir }, agentOptions: { model: 'dsagent-model' }, @@ -170,8 +170,8 @@ describe('HarnessSdkServer', () => { } as unknown as Agent const mainHandle = { agent: mainAgent, dispose: vi.fn(() => Promise.resolve()) } const otherHandle = { agent: otherAgent, dispose: vi.fn(() => Promise.resolve()) } - const create = vi.fn(async (options: { agentId: AgentId }) => - String(options.agentId) === 'main' ? mainHandle : otherHandle) + const create = vi.fn(async (options: { sessionId: SessionId }) => + String(options.sessionId) === 'main' ? mainHandle : otherHandle) const ctx = { on: vi.fn(() => () => undefined), agents: { create, get: () => undefined }, @@ -263,20 +263,18 @@ describe('HarnessSdkServer', () => { const server = new HarnessSdkServer(ctx, transport) const parentHandle = await ctx.agents.create({ - agentId: AgentId('parent-agent'), sessionId: SessionId('main'), meta: { cwd: storageDir }, agentOptions: { model: 'deepseek' }, }) const handle = await ctx.agents.create({ - agentId: AgentId('child-agent'), sessionId: SessionId('child-session'), meta: { cwd: storageDir, parentSession: SessionId('main') }, agentOptions: { model: 'deepseek' }, }) await settleSubagent(ctx, parentHandle.agent, { provider: 'spawn', - id: AgentId('child-agent'), + id: SessionId('child-session'), stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'child done' }], }) @@ -285,7 +283,7 @@ describe('HarnessSdkServer', () => { method: 'subagent.finished', params: { provider: 'spawn', - agentId: 'child-agent', + agentId: 'child-session', parentSessionId: 'main', childSessionId: 'child-session', status: 'ok', @@ -311,19 +309,16 @@ describe('HarnessSdkServer', () => { let failedHandle: AgentHandle | undefined try { parentHandle = await ctx.agents.create({ - agentId: AgentId('fallback-parent-agent'), sessionId: SessionId('fallback-parent'), meta: { cwd: storageDir }, agentOptions: { model: 'deepseek' }, }) handle = await ctx.agents.create({ - agentId: AgentId('fallback-child-agent'), sessionId: SessionId('fallback-child-session'), meta: { cwd: storageDir, parentSession: SessionId('fallback-parent') }, agentOptions: { model: 'deepseek' }, }) failedHandle = await ctx.agents.create({ - agentId: AgentId('failed-child-agent'), sessionId: SessionId('failed-child-session'), meta: { cwd: storageDir }, agentOptions: { model: 'deepseek' }, @@ -333,18 +328,18 @@ describe('HarnessSdkServer', () => { await settleSubagent(ctx, parentHandle.agent, { provider: 'fork', - id: AgentId('fallback-child-agent'), + id: SessionId('fallback-child-session'), stopReason: 'max-tokens', lastAssistantMessage: [], }) await settleSubagent(ctx, parentHandle.agent, { provider: 'fork', - id: AgentId('failed-child-agent'), + id: SessionId('failed-child-session'), stopReason: 'error', }) await settleSubagent(ctx, parentHandle.agent, { provider: 'fork', - id: AgentId('missing-child-agent'), + id: SessionId('missing-child-agent'), stopReason: 'error', }) @@ -352,7 +347,7 @@ describe('HarnessSdkServer', () => { method: 'subagent.finished', params: { provider: 'fork', - agentId: 'fallback-child-agent', + agentId: 'fallback-child-session', parentSessionId: 'fallback-parent', childSessionId: 'fallback-child-session', status: 'error', @@ -364,7 +359,7 @@ describe('HarnessSdkServer', () => { method: 'subagent.finished', params: { provider: 'fork', - agentId: 'failed-child-agent', + agentId: 'failed-child-session', childSessionId: 'failed-child-session', status: 'error', stopReason: 'error', diff --git a/packages/ui/stdio-agent/README.md b/packages/ui/stdio-agent/README.md index 27f0d20be7..82da64cd81 100644 --- a/packages/ui/stdio-agent/README.md +++ b/packages/ui/stdio-agent/README.md @@ -11,11 +11,11 @@ A terminal chat always wants the same cluster, so the package owns it rather tha | Plugin | Why it is here | |---|---| | `@cordisjs/plugin-logger-console` | the console logger — stdout is just the terminal here, so logging to it is correct (the ACP app must NOT have this) | -| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating a `main` agent from this app's `model` with `process.cwd()` as the fresh session cwd and carrying its `persona` | +| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating one agent under the `main` config label from this app's `model`, with `process.cwd()` as the fresh session cwd and carrying its `persona` | | `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` | | `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools | | `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool | -| `stdio-chat` (in-package module) | the readline UI, bound to the `main` agent | +| `stdio-chat` (in-package module) | the readline UI, holding the app-owned agent object directly and rendering it as `main` | `@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`. @@ -25,14 +25,14 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte | Key | Default | Routed to | |---|---|---| -| `model` | (required) | the pre-created `main` agent's model | +| `model` | (required) | the pre-created agent's model | | `persona` | — | the deployment persona template (may reference `{{model}}`), routed to `dsh-system-prompt` | | `toolOrder` | — | explicit model-facing tool order (a name list with one `''` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` | | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | | `welcome` | `ready.` | the stdin-chat banner | | `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) | -Fresh stdio sessions use the process launch directory as `session.header.cwd`, so project-scoped features such as skill discovery and default bash workdir follow the directory where `dsh-stdio-agent` was started. Resumed sessions keep the cwd stored in the persisted session header. +Fresh stdio sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-` agent/session id, so durable restarts cannot collide. The UI's `main` text is a display label, not a second routing id. Resumed sessions register under the exact `resumeSessionId` and keep the cwd stored in the persisted session header. ## The bin diff --git a/packages/ui/stdio-agent/src/index.ts b/packages/ui/stdio-agent/src/index.ts index 127851eaf5..f01c48a615 100644 --- a/packages/ui/stdio-agent/src/index.ts +++ b/packages/ui/stdio-agent/src/index.ts @@ -3,11 +3,11 @@ * @deepseek-ai/dsh-agent-core}) plus the coupled front-door cluster a terminal * chat needs — a console logger, the readline UI (the in-package `stdio-chat` * module), JSONL session - * persistence, and a pre-created `main` agent the UI drives. + * persistence, and one pre-created agent the UI drives under its `main` label. * * The cluster is BAKED IN, not left to the leaf: a stdio app always logs to the - * console (stdout is just the terminal) and always pre-creates the `main` agent - * the readline UI sends to. The leaf supplies the swappable backends (the LLM + * console (stdout is just the terminal) and always pre-creates one agent the + * readline UI labels `main`. The leaf supplies the swappable backends (the LLM * adapter, the bash executor), optional product tools, the optional `hmr` * dev-reload plugin, and this app's {@link Config} (model, prompt, persistence * root, welcome banner). @@ -41,7 +41,6 @@ import type { Context } from 'cordis' import ConsoleExporter from '@cordisjs/plugin-logger-console' import z from 'schemastery' -import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' import * as agentCore from '@deepseek-ai/dsh-agent-core' @@ -54,7 +53,7 @@ export const name = 'stdio-agent' /** * App config: the swappable per-demo values, each routed to where the app wires - * it. `model`/`resumeSessionId` configure the pre-created `main` agent (through + * it. `model`/`resumeSessionId` configure the pre-created agent (through * {@link @deepseek-ai/dsh-agent-core}'s forwarded `agents` list); `persona` is * the deployment persona (forwarded to the system-prompt plugin); `toolOrder` * is the explicit model-facing tool order (forwarded to the system-prompt plugin); @@ -63,7 +62,7 @@ export const name = 'stdio-agent' * `welcome` is the UI banner. */ export interface Config { - /** Model name for the `main` agent (must have a registered adapter). */ + /** Model name for the pre-created agent (must have a registered adapter). */ model: string /** Deployment persona (the system-prompt plugin's `persona` config). */ persona?: string @@ -78,7 +77,7 @@ export interface Config { /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */ skills?: agentCore.SkillConfig /** - * If set, the `main` agent RESUMES this persisted session id instead of + * If set, the pre-created agent RESUMES this persisted session id instead of * starting fresh. Sourced from an env var in the leaf `cordis.yml` * (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`). */ @@ -103,9 +102,9 @@ export const Config: z = z.object({ /** * Compose the spine with the stdio front door. The console logger comes first - * (infra), then the agent-core bundle pre-creating the `main` agent from this - * app's `model`/`resumeSessionId` with the deployment `persona`, then the JSONL - * backend, then the readline UI bound to `main`. The `hmr` dev-reload plugin is + * (infra), then the agent-core bundle pre-creating one agent from this app's + * `model`/`resumeSessionId` with the deployment `persona`, then the JSONL + * backend, then the readline UI rendering that object as `main`. The `hmr` dev-reload plugin is * a leaf concern (see the module doc), so it is not mounted here. */ export function apply(ctx: Context, config: Config): void { @@ -115,7 +114,7 @@ export function apply(ctx: Context, config: Config): void { ...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {}, ...config.tools !== undefined ? { tools: config.tools } : {}, agents: [{ - id: AgentId('main'), + id: 'main', model: config.model, cwd: process.cwd(), ...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {}, @@ -125,5 +124,5 @@ export function apply(ctx: Context, config: Config): void { ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' }) ctx.plugin(UserInteractionService) ctx.plugin(toolAskUser) - ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.', agent: 'main' }) + ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.' }) } diff --git a/packages/ui/stdio-agent/src/stdio-chat.ts b/packages/ui/stdio-agent/src/stdio-chat.ts index 8e3c1f467b..cb9ce0bc10 100644 --- a/packages/ui/stdio-agent/src/stdio-chat.ts +++ b/packages/ui/stdio-agent/src/stdio-chat.ts @@ -19,7 +19,7 @@ import { createInterface } from 'node:readline' import type { Readable, Writable } from 'node:stream' import type { Context } from 'cordis' import z from 'schemastery' -import { AgentId } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' import { UserInteractionError, type AskUserQuestionAnswer, @@ -36,15 +36,10 @@ export const inject = ['agents', 'userInteraction'] export interface Config { /** Banner printed once on start, before the first `> ` prompt. */ welcome?: string - // TODO(fixed-stdio-agent): this app-internal plugin is mounted only for the - // precreated `main` agent; remove configurability and its config-only test. - /** Id of the agent stdin drives (`send`/`steer`) and whose status gates the EOF exit; rendering is global. Defaults to `'main'`. */ - agent?: string } export const Config: z = z.object({ welcome: z.string().default('ready.'), - agent: z.string().default('main'), }) /** @@ -98,23 +93,16 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt // Loader validation, so it must be self-contained rather than trusting the // cast — `config.welcome as string` would otherwise be `undefined` on `{}`. const welcome = config.welcome ?? 'ready.' - const agentId = AgentId(config.agent ?? 'main') const { input, output, exit } = runtime - // Render label lookup: the `turn/start` session event carries only the turn - // number, so to print the short agent id (`[main turn 1]`) we map the - // session's id to its agent's id. The session id is not reliably the agent id - // (a session can be created with an explicit/client-supplied id), so build the - // map from `agent/created` rather than parsing the id string. Seed from the - // registry's current agents first: an agent registered before this plugin - // installed (e.g. the pre-created `main` agent, or any agent surviving an HMR - // reload of just this fiber) already fired its `agent/created`, so the live - // listener alone would miss it and its turns would fall back to the raw - // session id. - const labelBySession = new Map() - for (const agent of ctx.agents.list()) labelBySession.set(agent.session.header.id, agent.id) - ctx.on('agent/created', (agent) => { labelBySession.set(agent.session.header.id, agent.id) }) - ctx.on('agent/disposed', (agent) => { labelBySession.delete(agent.session.header.id) }) + // This app owns exactly one pre-created agent. Hold the live object directly: + // its per-run id is intentionally fresh, while `main` remains only the + // terminal's fixed display label. + let target: Agent | undefined = ctx.agents.list()[0] + ctx.on('agent/created', (agent) => { target ??= agent }) + ctx.on('agent/disposed', (agent) => { + if (target === agent) target = undefined + }) // Transcript rendering off the durable `session/event` feed — the assistant // token stream, turn/step boundaries, tool activity, and todos all come from @@ -136,7 +124,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt output.write(chunk.text) } } else if (event.type === 'turn/start') { - const label = labelBySession.get(session.header.id) ?? session.header.id + const label = target?.session === session ? 'main' : session.id output.write(`\n[${label} turn ${event.data.turn}] `) } else if (event.type === 'turn/end') { if (inReasoning) output.write('\x1B[0m') @@ -187,7 +175,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt // Work submitted: wait until a turn has run and the agent is idle. if (submittedWork) { if (!sawRunning) return - const agent = ctx.agents.get(agentId) + const agent = target if (agent && agent.status !== 'idle') return // a turn is still running } // Let any final output flush, then exit. The handle is tracked so the @@ -201,7 +189,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt } const disposeStatusListener = ctx.on('agent/status', (subject, status) => { - if (subject.id !== agentId) return + if (subject !== target) return if (status === 'running') sawRunning = true if (status === 'idle') maybeExit() }) @@ -354,9 +342,9 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt } const text = line.trim() if (!text) return - const agent = ctx.agents.get(agentId) + const agent = target if (!agent) { - ctx.logger.error('ui-stdio: agent "%s" is not running', agentId) + ctx.logger.error('ui-stdio: main agent is not running') return } submittedWork = true diff --git a/packages/ui/stdio-agent/tests/readline.spec.ts b/packages/ui/stdio-agent/tests/readline.spec.ts index a958c435c1..6116aa356f 100644 --- a/packages/ui/stdio-agent/tests/readline.spec.ts +++ b/packages/ui/stdio-agent/tests/readline.spec.ts @@ -16,7 +16,7 @@ function fakeContext(): Context { return { on: vi.fn(() => vi.fn()), effect: vi.fn((callback: () => () => void) => callback()), - // The UI seeds its label map from the registry at install; this suite only + // The UI seeds its target object from the registry at install; this suite only // exercises readline terminal-mode selection, so an empty roster suffices. agents: { list: vi.fn(() => []) }, userInteraction: { registerProvider: vi.fn(() => vi.fn()) }, diff --git a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts index 0668d25fb0..5c5c0d2667 100644 --- a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts @@ -4,7 +4,8 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import { AgentId, agentEvents, type Agent } from '@deepseek-ai/dsh-agent' +import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' + import type { Message } from '@deepseek-ai/dsh-llm' import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import * as stdioAgent from '../src/index.ts' @@ -82,9 +83,12 @@ describe('dsh-stdio-agent app', () => { expect(ctx.get('sessionPersistence')).toBeDefined() expect(ctx.get('userInteraction')).toBeDefined() expect(ctx.get('tools')?.get('ask_user_question')).toBeDefined() - // The pre-created `main` agent the UI drives. - const agent = ctx.get('agents')?.get(AgentId('main')) + // The sole pre-created agent the UI drives. `main` is its stable config + // label; each fresh process mints a durable combined agent/session id. + const agent = ctx.get('agents')?.list()[0] expect(agent).toBeDefined() + expect(agent?.id).toBe(agent?.session.id) + expect(agent?.id).toMatch(/^main-session-/) expect(agent?.session.header.cwd).toBe(process.cwd()) await ctx.fiber.dispose() }) @@ -99,7 +103,7 @@ describe('dsh-stdio-agent app', () => { stdioAgent.apply(ctx, { model: 'mock', skills: await isolatedSkillsConfig() }) await new Promise(resolve => setTimeout(resolve, 80)) expect(ctx.get('sessionPersistence')).toBeDefined() - expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined() + expect(ctx.get('agents')?.list()).toHaveLength(1) await ctx.fiber.dispose() }) @@ -116,7 +120,7 @@ describe('dsh-stdio-agent app', () => { it('forwards resumeSessionId onto the pre-created agent when set', async () => { // A resume id defers agent creation until persistence loads; with no backing - // session the resume is contained + logged, so no `main` agent registers — + // session the resume is contained + logged, so no agent registers — // the branch that maps resumeSessionId through is what this covers. const ctx = await mount({ model: 'mock', @@ -125,7 +129,7 @@ describe('dsh-stdio-agent app', () => { resumeSessionId: 'no-such-session', skills: await isolatedSkillsConfig(), }) - expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined() + expect(ctx.get('agents')?.list()).toEqual([]) await ctx.fiber.dispose() }) diff --git a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts index 93683d0768..69ca5f8837 100644 --- a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts @@ -57,17 +57,16 @@ function makeAgent(id: string, status: AgentStatus = 'idle'): Agent & { status, sent, steered, - // A minimal session stub: the UI reads only `session.header.id` (to map the - // session back to its agent id for the turn-boundary label). - session: { header: { id: `${id}-session` } }, + // A minimal session stub with the agent's shared durable identity. + session: { id, header: { id } }, send: (content: ContentBlock[]) => void sent.push(content), steer: (content: ContentBlock[]) => void steered.push(content), } as never } /** A session stub whose `header.id` matches an agent's, for `session/event` emits. */ -function makeSession(agentId: string): Session { - return { header: { id: `${agentId}-session` } } as Session +function makeSession(id: string): Session { + return { id, header: { id } } as Session } /** An `assistant/chunk` session event carrying one raw stream chunk. */ @@ -75,7 +74,7 @@ function chunkEvent(chunk: StreamChunk): SessionEvent { return { type: 'assistant/chunk', seq: 0, time: 0, data: { turn: 1, step: 0, chunk } } } -const CONFIG: Config = { welcome: 'hi there', agent: 'main' } +const CONFIG: Config = { welcome: 'hi there' } async function setup(config: Config = CONFIG, runtimeOver: Partial = {}) { const ctx = new Context() @@ -99,12 +98,11 @@ describe('createStdioChat rendering', () => { expect(out.text()).toBe('hi there\n> ') }) - it('falls back to default welcome/agent when called with empty config', async () => { + it('falls back to the default welcome when called with empty config', async () => { // createStdioChat is exported and may be driven directly (bypassing the - // Loader's schemastery validation), so it must default welcome/agent itself. + // Loader's schemastery validation), so it must default the welcome itself. const { out } = await setup({}) expect(out.text()).toBe('ready.\n> ') - // And it drives the default agent id 'main'. }) it('detects readline terminal mode from both stream TTY flags', async () => { @@ -156,9 +154,9 @@ describe('createStdioChat rendering', () => { it('renders turn/start and turn/end markers from the session feed', async () => { const { ctx, out } = await setup() const agent = makeAgent('main') - // agent/created populates the session-id → agent-id label map. + // agent/created supplies the app-owned target object. ctx.emit('agent/created', agent) - const session = makeSession('main') + const session = agent.session ctx.emit('session/event', session, { type: 'turn/start', seq: 1, time: 0, data: { turn: 3, trigger: { kind: 'message' } }, } as SessionEvent) @@ -169,21 +167,20 @@ describe('createStdioChat rendering', () => { expect(out.text()).toContain('\n> ') }) - it('falls back to the session id as the label when no agent is mapped', async () => { + it('uses the session id as the label for a non-target session', async () => { const { ctx, out } = await setup() - // No agent/created emitted, so the label map is empty — the header id shows. + // No target exists, so the event's durable identity is the label. ctx.emit('session/event', makeSession('orphan'), { type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } }, } as SessionEvent) - expect(out.text()).toContain('[orphan-session turn 1] ') + expect(out.text()).toContain('[orphan turn 1] ') }) - it('seeds labels for agents already registered before the UI installs', async () => { + it('uses an agent already registered before the UI installs as its target', async () => { // The pre-created `main` agent (and any agent surviving an HMR reload of just // this fiber) fired its `agent/created` before the UI's listener existed, so // the live listener alone would miss it. Seeding from `ctx.agents.list()` at - // install time is what keeps its turn header showing `[main turn N]` instead - // of the raw session id. + // install time preserves the terminal's fixed `[main turn N]` label. const ctx = new Context() await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) @@ -193,7 +190,7 @@ describe('createStdioChat rendering', () => { await ctx.plugin(Object.assign((inner: Context) => { createStdioChat(inner, CONFIG, runtime) }, { inject: ['agents', 'userInteraction'] })) - ctx.emit('session/event', makeSession('main'), { + ctx.emit('session/event', agent.session, { type: 'turn/start', seq: 1, time: 0, data: { turn: 5, trigger: { kind: 'message' } }, } as SessionEvent) expect(out.text()).toContain('[main turn 5] ') @@ -209,17 +206,28 @@ describe('createStdioChat rendering', () => { expect(out.text()).toContain('\x1B[2mmid\x1B[0m') }) - it('drops the label mapping on agent/disposed', async () => { + it('drops the target object on agent/disposed', async () => { const { ctx, out } = await setup() const agent = makeAgent('main') ctx.emit('agent/created', agent) ctx.emit('agent/disposed', agent) - // After disposal the map no longer resolves the agent id — fall back to the - // session header id. - ctx.emit('session/event', makeSession('main'), { + // After disposal the event belongs to a non-target session, so its durable + // identity is rendered directly. + ctx.emit('session/event', agent.session, { type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } }, } as SessionEvent) - expect(out.text()).toContain('[main-session turn 1] ') + expect(out.text()).toContain('[main turn 1] ') + }) + + it('keeps the target when a different agent is disposed', async () => { + const { ctx, out } = await setup() + const target = makeAgent('target') + ctx.emit('agent/created', target) + ctx.emit('agent/disposed', makeAgent('other')) + ctx.emit('session/event', target.session, { + type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } }, + } as SessionEvent) + expect(out.text()).toContain('[main turn 1] ') }) it('renders tool/call and tool/result session events', async () => { @@ -666,11 +674,11 @@ describe('createStdioChat input', () => { const spy = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) input.feed('nobody home') await new Promise(r => setImmediate(r)) - expect(spy).toHaveBeenCalledWith('ui-stdio: agent "%s" is not running', 'main') + expect(spy).toHaveBeenCalledWith('ui-stdio: main agent is not running') }) - it('drives the agent named in config, not a hardcoded id', async () => { - const { ctx, input } = await setup({ welcome: 'w', agent: 'worker' }) + it('drives the app-owned agent without a duplicate id config', async () => { + const { ctx, input } = await setup({ welcome: 'w' }) const agent = makeAgent('worker') ctx.agents.register(agent) input.feed('hi') diff --git a/packages/util/brand/README.md b/packages/util/brand/README.md index 8f7943def7..be25edd55e 100644 --- a/packages/util/brand/README.md +++ b/packages/util/brand/README.md @@ -4,7 +4,7 @@ The `Branded` nominal-typing primitive — a tiny, **type-only** package (no ## What `Branded` is -A brand makes structurally-identical strings non-interchangeable at the type level: an `AgentId` cannot be passed where a `CallId` is expected, even though both are plain `string`s at runtime. +A brand makes structurally-identical strings non-interchangeable at the type level: a `SessionId` cannot be passed where a `CallId` is expected, even though both are plain `string`s at runtime. ```ts import type { Branded } from '@deepseek-ai/dsh-brand' @@ -21,6 +21,6 @@ Construction goes through the per-id factory in the OWNING package (a plain cast ## 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.** +A package brands the ids it OWNS — `CallId` in `dsh-llm` (tool-call correlation), the shared agent/session `SessionId` in `dsh-session`, and `BashTaskId`/`OwnerToken` in `dsh-bash`. Branding is for ids that cross package boundaries and could plausibly be confused; **not every string needs a brand.** This package owns ONLY the primitive — no concrete id, no runtime code beyond the (erased) type. Keeping the primitive dependency-free is the point: a capability package can brand its ids without depending on an unrelated package. `dsh-bash`, for example, brands `BashTaskId`/`OwnerToken` by depending on `dsh-brand` alone — it never pulls in `dsh-llm` (or `dsh-session`) just to reach `Branded`. diff --git a/packages/util/brand/src/index.ts b/packages/util/brand/src/index.ts index 051ced94b7..d8c28846b3 100644 --- a/packages/util/brand/src/index.ts +++ b/packages/util/brand/src/index.ts @@ -4,15 +4,15 @@ * cross-boundary id. * * A brand makes structurally-identical strings non-interchangeable at the type - * level: an `AgentId` cannot be passed where a `CallId` is expected, even + * level: a `SessionId` cannot be passed where a `CallId` is expected, even * though both are plain strings at runtime. Construction goes through a per-id * factory in the OWNING package (a plain cast inside — zero runtime cost); * comparison, logging, and serialization all behave as ordinary strings. * * Policy: a package brands the ids it owns — `CallId` in dsh-llm (tool-call - * correlation), `SessionId` in dsh-session, `AgentId` in dsh-agent, - * `BashTaskId`/`OwnerToken` in dsh-bash. Branding is for ids that cross package - * boundaries and could plausibly be confused; not every string needs a brand. + * correlation), and `SessionId` in dsh-session; `BashTaskId`/`OwnerToken` live + * 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 diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index 0585889617..ac7112a0f0 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -4,7 +4,6 @@ import Loader from '@cordisjs/plugin-loader' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' -import { AgentId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { WorkflowRunId, WorkflowService } from '@deepseek-ai/dsh-workflow' import type { WorkflowResult, WorkflowRun, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow' @@ -12,6 +11,7 @@ import { CallId } from '@deepseek-ai/dsh-llm' import SubagentService from '@deepseek-ai/dsh-subagent' import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread' import * as toolWorkflow from '../src/index.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** A controllable engine standing in behind ctx.workflows (the tool's only seam). */ class StubEngine extends WorkflowService { @@ -51,7 +51,7 @@ async function setup(config?: { toolName?: string; maxResultChars?: number }) { await ctx.plugin(StubEngine) await ctx.plugin(toolWorkflow, config ?? {}) const engine = ctx.workflows as StubEngine - const parent = { id: AgentId('caller'), options: {} } as unknown as Agent + const parent = { id: SessionId('caller'), options: {} } as unknown as Agent return { ctx, engine, parent } } @@ -237,7 +237,7 @@ describe('dsh-tool-workflow', () => { await ctx.plugin(SubagentService) await ctx.plugin(WorkerWorkflowEngine, { disposeGraceMs: 30 }) await ctx.plugin(toolWorkflow, {}) - const parent = { id: AgentId('caller'), options: {} } as unknown as Agent + const parent = { id: SessionId('caller'), options: {} } as unknown as Agent const controller = new AbortController() const pending = execute(ctx, { script: 'await new Promise(() => {})\nreturn 1', diff --git a/packages/workflow/workflow-workerthread/src/runtime.ts b/packages/workflow/workflow-workerthread/src/runtime.ts index 94282cb173..7dbdccbb43 100644 --- a/packages/workflow/workflow-workerthread/src/runtime.ts +++ b/packages/workflow/workflow-workerthread/src/runtime.ts @@ -38,8 +38,8 @@ */ import * as vm from 'node:vm' -import { AgentId } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' import { assertSupportedOutputSchema, OutputSchemaError } from '@deepseek-ai/dsh-tools' import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools' import { isFatalWorkflowError, WorkflowError } from '@deepseek-ai/dsh-workflow' @@ -319,7 +319,7 @@ export class WorkflowExecution { await run.dispose() throw this.cancelledError() } - const info: WorkflowAgentInfo = { seq, label, ...phase !== undefined ? { phase } : {}, childId: AgentId(run.id) } + const info: WorkflowAgentInfo = { seq, label, ...phase !== undefined ? { phase } : {}, childId: SessionId(run.id) } this.observer.agentStart(info) try { let result diff --git a/packages/workflow/workflow-workerthread/tests/integration.spec.ts b/packages/workflow/workflow-workerthread/tests/integration.spec.ts index 0e1727f877..2e13ddcb8b 100644 --- a/packages/workflow/workflow-workerthread/tests/integration.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/integration.spec.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService from '@deepseek-ai/dsh-subagent' @@ -37,7 +38,7 @@ async function setup(script: Script) { await ctx.plugin(spawn, { providerName: 'spawn' }) await ctx.plugin(WorkerWorkflowEngine, {}) ctx.llm.registerAdapter(['mock'], adapter) - const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) + const parent = ctx.agentLoop.create(SessionId('parent'), { model: 'mock' }) return { ctx, parent, adapter } } @@ -73,7 +74,7 @@ return { prose, verdict: judged.verdict, confidence: judged.confidence }`, // Both children were disposed to quiescence — no live child agents remain. expect(childIds.length).toBe(2) for (const childId of childIds) { - expect(ctx.agents.get(AgentId(childId))).toBeUndefined() + expect(ctx.agents.get(SessionId(childId))).toBeUndefined() } }) diff --git a/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts b/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts index 7f34396d52..673a43ee0a 100644 --- a/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts @@ -6,10 +6,10 @@ import { expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' import WorkerWorkflowEngine from '../src/index.ts' +import { SessionId } from '@deepseek-ai/dsh-session' // A fresh thread compiles the source runtime. Leave contention headroom on // shared CI runners without weakening any engine-level timeout assertion. @@ -19,7 +19,7 @@ it('runs the default config through the source worker', async () => { const ctx = new Context() const subagents = await ctx.plugin(SubagentService) const engine = await ctx.plugin(WorkerWorkflowEngine, {}) - const parent = { id: AgentId('workflow-compat-parent'), options: {} } as unknown as Agent + const parent = { id: SessionId('workflow-compat-parent'), options: {} } as unknown as Agent try { const run = ctx.workflows.start({ script: 'return 6 * 7', diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts index 1d6f61432a..169d80cda7 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts @@ -1,10 +1,11 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SubagentService from '@deepseek-ai/dsh-subagent' @@ -62,7 +63,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('worker workflow engine with-key it('runs a two-phase script in a worker thread over real children, one through the structured runtime', async () => { ctx = await harness() const parentHandle = await ctx.agents.create({ - agentId: AgentId('wf-worker-e2e-parent'), sessionId: 'wf-worker-e2e-session' as never, agentOptions: { model: 'deepseek-v4-flash' }, }) @@ -95,7 +95,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('worker workflow engine with-key expect(childIds.length).toBe(2) // The children were disposed to quiescence after collection. for (const childId of childIds) { - expect(ctx.agents.get(AgentId(childId))).toBeUndefined() + expect(ctx.agents.get(SessionId(childId))).toBeUndefined() } await parentHandle.dispose() }, 240_000) diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index 4ad00ee02f..09fb606d69 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -3,17 +3,17 @@ import { fileURLToPath } from 'node:url' import type { Worker } from 'node:worker_threads' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import { AgentId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentCapabilities, SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import type { WorkflowMeta, WorkflowResult, WorkflowResultInfo, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow' import * as workerEngineModule from '../src/index.ts' import WorkerWorkflowEngine, { HostToWorkerType, WorkerToHostType, type Config } from '../src/index.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** A minimal parent stand-in: the engine only threads it through to the provider. */ function fakeParent(): Agent { - return { id: AgentId('workflow-parent'), options: {} } as unknown as Agent + return { id: SessionId('workflow-parent'), options: {} } as unknown as Agent } // Worker-thread startup is CPU-bound (a fresh thread compiles the runtime on @@ -117,7 +117,7 @@ class StubProvider implements SubagentProvider { } if (request.signal.aborted) throw new Error('child start aborted before publication') return { - id: AgentId(`stub-child-${index}`), + id: SessionId(`stub-child-${index}`), result: terminal.promise, dispose: () => { controlled.disposeCalls += 1 @@ -366,7 +366,7 @@ describe('dsh-workflow-workerthread', () => { capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, inheritsParentContext: false, start: async () => ({ - id: AgentId('reject-child'), + id: SessionId('reject-child'), result: Promise.reject(new Error('backend exploded')), dispose: () => Promise.resolve(), }), @@ -400,7 +400,7 @@ describe('dsh-workflow-workerthread', () => { stopReason: 'completed', } as unknown as SubagentResult const start = vi.spyOn(ctx.subagents, 'start').mockResolvedValue({ - id: AgentId('raw-invalid-child'), + id: SessionId('raw-invalid-child'), result: Promise.resolve(invalid), dispose: () => Promise.resolve(), }) @@ -423,7 +423,7 @@ describe('dsh-workflow-workerthread', () => { capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, inheritsParentContext: false, start: async () => ({ - id: AgentId('bad-dispose-child'), + id: SessionId('bad-dispose-child'), result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }), cancel: () => { /* settled already */ }, dispose: () => { throw new Error('dispose exploded') }, @@ -444,7 +444,7 @@ describe('dsh-workflow-workerthread', () => { capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, inheritsParentContext: false, start: async () => ({ - id: AgentId('trap-child'), + id: SessionId('trap-child'), result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }), cancel: () => { /* settled already */ }, // The rejection VALUE's own coercion throws: a warn built with bare @@ -771,7 +771,7 @@ describe('dsh-workflow-workerthread', () => { settle({ output: [], stopReason: 'aborted' }) }, { once: true }) return { - id: AgentId('signal-only-child'), + id: SessionId('signal-only-child'), result, dispose: () => Promise.resolve(), } @@ -1092,7 +1092,7 @@ describe('dsh-workflow-workerthread', () => { expect(request.signal.reason).toBe('workflow worker gone') ready.resolve({ - id: AgentId('late-ready-child'), + id: SessionId('late-ready-child'), result: Promise.resolve({ output: [], stopReason: 'aborted' }), dispose: () => { disposeCalls += 1 @@ -1128,7 +1128,7 @@ describe('dsh-workflow-workerthread', () => { handle.cancel('reentered from worker-death signal cleanup') }, { once: true }) return { - id: AgentId('doomed-child'), + id: SessionId('doomed-child'), result: new Promise(() => { /* never settles; the reap is the teardown */ }), dispose: () => Promise.reject(new Error('dispose exploded during reap')), } diff --git a/packages/workflow/workflow/src/types.ts b/packages/workflow/workflow/src/types.ts index 981a2da172..35ea4fb053 100644 --- a/packages/workflow/workflow/src/types.ts +++ b/packages/workflow/workflow/src/types.ts @@ -7,7 +7,8 @@ */ import type { Branded } from '@deepseek-ai/dsh-brand' -import type { Agent, AgentId } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { SessionId } from '@deepseek-ai/dsh-session' /** Identifies one workflow run. */ export type WorkflowRunId = Branded<'WorkflowRunId'> @@ -145,7 +146,7 @@ export interface WorkflowAgentInfo { /** The phase this agent belongs to (the `phase` option, else the current `phase()` title). */ phase?: string /** The child agent's id on the subagent seam. */ - childId: AgentId + childId: SessionId } /** How one `agent()` call settled: clean result, child failure (script sees `null`), or run cancellation. */