refactor: unify agent and session identity
This commit is contained in:
@@ -31,7 +31,7 @@ export interface AcpConfig {
|
|||||||
|
|
||||||
Depends on: `Stream` (`@agentclientprotocol/sdk`)
|
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`
|
## `@deepseek-ai/dsh-acp-agent`
|
||||||
|
|
||||||
@@ -117,8 +117,8 @@ Requires: `agents` · `sessions` · `llm` · `tools` · `systemPrompt`
|
|||||||
export interface Config {
|
export interface Config {
|
||||||
/** Agents created or resumed at plugin startup. */
|
/** Agents created or resumed at plugin startup. */
|
||||||
agents: (AgentOptions & {
|
agents: (AgentOptions & {
|
||||||
/** Registry identity for the live agent. */
|
/** Stable config label used in logs and as the fresh combined-id prefix. */
|
||||||
id: AgentId
|
id: string
|
||||||
/** Optional workspace for a fresh session. */
|
/** Optional workspace for a fresh session. */
|
||||||
cwd?: string
|
cwd?: string
|
||||||
/** Persisted session to resume instead of creating a fresh session. */
|
/** 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`
|
## `@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
|
```ts config-catalog
|
||||||
/**
|
/**
|
||||||
* App config: the swappable per-demo values, each routed to where the app wires
|
* 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
|
* {@link @deepseek-ai/dsh-agent-core}'s forwarded `agents` list); `persona` is
|
||||||
* the deployment persona (forwarded to the system-prompt plugin); `toolOrder`
|
* the deployment persona (forwarded to the system-prompt plugin); `toolOrder`
|
||||||
* is the explicit model-facing tool order (forwarded to the system-prompt plugin);
|
* 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.
|
* `welcome` is the UI banner.
|
||||||
*/
|
*/
|
||||||
export interface Config {
|
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
|
model: string
|
||||||
/** Deployment persona (the system-prompt plugin's `persona` config). */
|
/** Deployment persona (the system-prompt plugin's `persona` config). */
|
||||||
persona?: string
|
persona?: string
|
||||||
@@ -635,7 +635,7 @@ export interface Config {
|
|||||||
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */
|
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */
|
||||||
skills?: agentCore.SkillConfig
|
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`
|
* starting fresh. Sourced from an env var in the leaf `cordis.yml`
|
||||||
* (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`).
|
* (`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)
|
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`
|
## `@deepseek-ai/dsh-subagent-acp`
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ A UI plugin renders from the `session/event` feed (the assistant token stream as
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
import type { Context } from 'cordis'
|
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 render(text: string): void
|
||||||
declare function onUserInput(handler: (text: string) => void): void
|
declare function onUserInput(handler: (text: string) => void): void
|
||||||
@@ -50,7 +50,7 @@ export function apply(ctx: Context) {
|
|||||||
render(event.data.chunk.text)
|
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 }]))
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
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
|
### `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)
|
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
|
### `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)
|
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
|
### `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)
|
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
|
### `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)
|
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
|
### `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)
|
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
|
### `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)
|
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
|
### `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)
|
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
|
### `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)
|
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
|
### `agent/status` — emit
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle
|
|||||||
|
|
||||||
Types: [Agent](../core-data-structures/core.md)
|
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
|
### `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)
|
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
|
### `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)
|
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
|
### `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)
|
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/*`
|
## `approval/*`
|
||||||
|
|
||||||
@@ -317,7 +317,7 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c
|
|||||||
'subagent/end'(this: Scoped<SubagentService>, info: SubagentRunEndInfo): void
|
'subagent/end'(this: Scoped<SubagentService>, 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
|
### `subagent/provider-added` — emit
|
||||||
|
|
||||||
@@ -327,7 +327,7 @@ A provider became resolvable in the registry.
|
|||||||
'subagent/provider-added'(provider: SubagentProvider): void
|
'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
|
### `subagent/provider-removed` — emit
|
||||||
|
|
||||||
@@ -337,7 +337,7 @@ A provider left the registry. Accepted runs remain holder-owned.
|
|||||||
'subagent/provider-removed'(name: string): void
|
'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
|
### `subagent/start` — emit
|
||||||
|
|
||||||
@@ -347,7 +347,7 @@ A provider established a ready child. For in-process providers, `ctx.agents.get(
|
|||||||
'subagent/start'(this: Scoped<SubagentService>, info: SubagentRunInfo): void
|
'subagent/start'(this: Scoped<SubagentService>, 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/*`
|
## `system-prompt/*`
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary
|
|||||||
Concrete ReactLoopAgent factory and driver service.
|
Concrete ReactLoopAgent factory and driver service.
|
||||||
|
|
||||||
```ts cordis-catalog
|
```ts cordis-catalog
|
||||||
create(id: AgentId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): ReactLoopAgent
|
create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): ReactLoopAgent
|
||||||
async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>
|
async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>
|
||||||
async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>
|
async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>
|
||||||
```
|
```
|
||||||
|
|
||||||
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`
|
## `ctx.agents` — `AgentRegistry`
|
||||||
|
|
||||||
@@ -32,13 +32,13 @@ async resume(options: ResumeAgentOptions): Promise<AgentHandle>
|
|||||||
register(agent: Agent): () => void
|
register(agent: Agent): () => void
|
||||||
enter(agent: Agent): () => void
|
enter(agent: Agent): () => void
|
||||||
announce(agent: Agent): void
|
announce(agent: Agent): void
|
||||||
get(id: AgentId): Agent | undefined
|
get(id: SessionId): Agent | undefined
|
||||||
list(): Agent[]
|
list(): Agent[]
|
||||||
```
|
```
|
||||||
|
|
||||||
Types: [Agent](../core-data-structures/core.md)
|
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`
|
## `ctx.approval` — `ApprovalService`
|
||||||
|
|
||||||
@@ -218,7 +218,7 @@ list(): Session[]
|
|||||||
fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): 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`
|
## `ctx.skills` — `SkillService`
|
||||||
|
|
||||||
@@ -244,7 +244,7 @@ list(): string[]
|
|||||||
async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>
|
async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>
|
||||||
```
|
```
|
||||||
|
|
||||||
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`
|
## `ctx.systemPrompt` — `SystemPrompt`
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Source: [`packages/ui/user-approval/src/index.ts`](../../packages/ui/user-approv
|
|||||||
|
|
||||||
## Identity and outcome
|
## 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
|
```ts type-equiv
|
||||||
type ApprovalRequestId = Branded<'ApprovalRequestId'>
|
type ApprovalRequestId = Branded<'ApprovalRequestId'>
|
||||||
|
|||||||
@@ -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).
|
`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`
|
## Foreground runs: `BashRunResult`
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ Two large discriminated unions are the ones consumers `switch` over most: **`Str
|
|||||||
|
|
||||||
## Branded IDs
|
## 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<B>` primitive lives in its own type-only package, [dsh-brand](../../packages/util/brand) (no runtime code, no harness-package dependency), so any package can brand the ids it owns without depending on an unrelated capability package (e.g. dsh-bash brands `BashTaskId`/`OwnerToken` via dsh-brand alone, never pulling in dsh-llm).
|
The `Branded<B>` primitive lives in its own type-only package, [dsh-brand](../../packages/util/brand) (no runtime code, no harness-package dependency), so any package can brand the ids it owns without depending on an unrelated capability package (e.g. dsh-bash brands `BashTaskId`/`OwnerToken` via dsh-brand alone, never pulling in dsh-llm).
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ Source: [`packages/util/brand/src/index.ts`](../../packages/util/brand/src/index
|
|||||||
type Branded<B extends string> = string & { readonly [BRAND]: B }
|
type Branded<B extends string> = string & { readonly [BRAND]: B }
|
||||||
```
|
```
|
||||||
|
|
||||||
The three core IDs: `CallId` (correlates a tool call with its result; dsh-llm), `SessionId` (dsh-session), `AgentId` (dsh-agent). Each is `Branded<'CallId'>` etc. plus a same-named factory function. 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
|
## Content blocks and messages
|
||||||
|
|
||||||
@@ -254,7 +254,7 @@ Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types
|
|||||||
|
|
||||||
```ts type-equiv
|
```ts type-equiv
|
||||||
interface Agent {
|
interface Agent {
|
||||||
readonly id: AgentId
|
readonly id: SessionId
|
||||||
readonly options: AgentOptions
|
readonly options: AgentOptions
|
||||||
readonly session: Session
|
readonly session: Session
|
||||||
readonly status: AgentStatus
|
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
|
## Interception decisions
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ The handle the consumer holds after a provider has established a ready child. Th
|
|||||||
|
|
||||||
```ts type-equiv
|
```ts type-equiv
|
||||||
interface SubagentRun {
|
interface SubagentRun {
|
||||||
readonly id: AgentId
|
readonly id: SessionId
|
||||||
readonly result: Promise<SubagentResult>
|
readonly result: Promise<SubagentResult>
|
||||||
dispose(): Promise<void>
|
dispose(): Promise<void>
|
||||||
sendMessage?(content: ContentBlock[]): void
|
sendMessage?(content: ContentBlock[]): void
|
||||||
|
|||||||
@@ -7,19 +7,19 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
|||||||
|
|
||||||
| Event | Mode | Declared in | Dispatchers | Listeners |
|
| 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/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:331`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`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:605`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
| `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: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/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: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/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:360`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
| `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:485`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
| `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:537`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) |
|
| `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: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/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: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/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:552`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
| `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: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-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:588`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
|
| `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) |
|
| `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/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) |
|
| `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) |
|
| `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-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`) | - |
|
| `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/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:66`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
|
| `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:72`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`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:82`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
|
| `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/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`) | - |
|
| `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`) | - |
|
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:173`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
|
||||||
|
|||||||
@@ -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).
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
- **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).)
|
- **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
|
## Alternatives considered
|
||||||
|
|
||||||
|
|||||||
@@ -4,23 +4,23 @@ Status: implemented
|
|||||||
|
|
||||||
## Problem
|
## 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<B> = 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<B> = 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.
|
**Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-task id is a plain `string`: `BashTask.id: string` (`packages/bash/bash/src/types.ts`), carried as `string` through the whole executor seam (`BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/bash/bash/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateTaskId`, `assertTaskAccess`, the `task_id` schema arg in `packages/bash/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/bash/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/core/session/src/index.ts`). A bash task id and a session id are trivially swappable at a call site and the compiler says nothing. This is the headline case the user asked about, and it is a model-facing id (the model passes `task_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input.
|
||||||
|
|
||||||
The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` (`packages/bash/bash/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's `session.header.id` (`callerToken = (exec) => exec.agent?.session.header.id` in `packages/bash/tool-bash/src/index.ts`) — i.e. a `SessionId` wearing a `string` disguise. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the same `session.header.id`-as-owner alias that the [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) proposal calls the "bash owner-token alias hole".
|
The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` (`packages/bash/bash/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's `session.header.id` (`callerToken = (exec) => exec.agent?.session.header.id` in `packages/bash/tool-bash/src/index.ts`) — i.e. a `SessionId` wearing a `string` disguise. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the same `session.header.id`-as-owner alias that the [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) proposal calls the "bash owner-token alias hole".
|
||||||
|
|
||||||
**Gap 2 — brand erosion at the seams of the *already-branded* IDs.** Even `CallId`/`SessionId`/`AgentId` decay back to bare `string` at exactly the places confusion is most likely: the registry/store `Map` key types and most public method params. Representative sites: `SessionStore.store = new Map<string, Session>()` and `create`/`prepare(id?: string)`/`get(id: string)` (`packages/core/session/src/index.ts`); `AgentRegistry.store = new Map<string, Agent>()` and `register`/`get(id: string)` (`packages/core/agent/src/index.ts`); `ToolPresenter.pending = new Map<string, …>()` keyed by call id and `call(callId: string)`/`result(callId: string)` (`packages/ui/acp/src/index.ts`); the ACP session-id surface beyond the store map — `SessionRecord.sessionId: string`, `bySession = new WeakMap<Agent, string>()`, `loadingIds = new Set<string>()`, `requireSession(sessionId: string)`, and the exported `streamSessionEventUpdate(sessionId: string, …)` (`packages/ui/acp/src/index.ts`); and the persistence coordinator's `Map<string, …>` keyed by session id (`packages/session-persistence/session-persistence/src/coordinator.ts`). A brand that is dropped at the `Map` key buys nothing on lookups — the value of the existing brands is partly unrealized.
|
**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
|
## 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.
|
A type-only change. Brands are zero-cost casts; nothing about runtime behavior, serialization, comparison, or the wire format changes. The work is in three parts, all honoring the existing "not every string" policy.
|
||||||
|
|
||||||
- **Brand the bash task id.** Add `BashTaskId = Branded<'BashTaskId'>` plus its same-named factory in `packages/bash/bash/src/types.ts` (the package that *owns* the id), importing `Branded` from `@deepseek-ai/dsh-brand` exactly as `SessionId`/`AgentId` already do. The brand primitive lives in the dependency-free `dsh-brand` utility package precisely so `dsh-bash` can brand its ids by depending on it alone — it never pulls in `dsh-llm` (or `dsh-session`) just to reach `Branded`. Thread it through `BashTask.id`, the `BashExecutor` seam methods (`get`/`ownerOf`/`readOutput`/`kill`), the generation site in `dsh-bash-local` (brand the counter output once, at creation), and the `dsh-tool-bash` validate/access surface (`validateTaskId` returns a `BashTaskId`; `task_id` is branded at the tool boundary where the model's string arrives).
|
- **Brand the bash task id.** Add `BashTaskId = Branded<'BashTaskId'>` plus its same-named factory in `packages/bash/bash/src/types.ts` (the package that *owns* the id), importing `Branded` from `@deepseek-ai/dsh-brand` exactly as `SessionId` does. The brand primitive lives in the dependency-free `dsh-brand` utility package precisely so `dsh-bash` can brand its ids by depending on it alone — it never pulls in `dsh-llm` (or `dsh-session`) just to reach `Branded`. Thread it through `BashTask.id`, the `BashExecutor` seam methods (`get`/`ownerOf`/`readOutput`/`kill`), the generation site in `dsh-bash-local` (brand the counter output once, at creation), and the `dsh-tool-bash` validate/access surface (`validateTaskId` returns a `BashTaskId`; `task_id` is branded at the tool boundary where the model's string arrives).
|
||||||
|
|
||||||
- **Mint a distinct `OwnerToken` brand.** Add `OwnerToken = Branded<'OwnerToken'>` in `packages/bash/bash/src/types.ts`; type `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` as `OwnerToken | undefined`. The `dsh-tool-bash` consumer casts the agent's `session.header.id` (a `SessionId`) into an `OwnerToken` at the boundary — the one place the two vocabularies meet. The bash seam never imports `dsh-session`. (Rationale in the next section.)
|
- **Mint a distinct `OwnerToken` brand.** Add `OwnerToken = Branded<'OwnerToken'>` in `packages/bash/bash/src/types.ts`; type `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` as `OwnerToken | undefined`. The `dsh-tool-bash` consumer casts the agent's `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<SessionId, Session>`, `get(id: SessionId)`, `Map<AgentId, Agent>`, `Map<CallId, …>`, the ACP `SessionRecord.sessionId: SessionId` surface, the coordinator's `Map<SessionId, …>`. 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<SessionId, Session>`, `Map<SessionId, Agent>`, `get(id: SessionId)`, `Map<CallId, …>`, ACP's `SessionId` surface, and the coordinator's `Map<SessionId, …>`. 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):
|
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
|
## 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
|
## Consequences
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
**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
|
## Consequences
|
||||||
|
|
||||||
|
|||||||
@@ -60,8 +60,7 @@ The ordinary contributor pattern is to register the complete local world during
|
|||||||
|
|
||||||
```js
|
```js
|
||||||
const handle = await ctx.agents.create({
|
const handle = await ctx.agents.create({
|
||||||
agentId: AgentId('reviewer'),
|
sessionId: SessionId('reviewer'),
|
||||||
sessionId: SessionId('reviewer-session'),
|
|
||||||
agentOptions: { model: 'model-name' },
|
agentOptions: { model: 'model-name' },
|
||||||
setup(agentCtx) {
|
setup(agentCtx) {
|
||||||
agentCtx.systemPrompt.section({
|
agentCtx.systemPrompt.section({
|
||||||
|
|||||||
@@ -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 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.
|
- **`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/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
|
### 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:
|
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.
|
- **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
|
### Reminder delivery
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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 `[<agent> 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
|
## 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
|
## 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)).
|
- **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
|
## 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.
|
||||||
|
|||||||
@@ -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-codex` — spawns `codex app-server` and drives one thread/turn over its JSON-RPC-over-stdio protocol with a hand-rolled newline-JSON client (~200–300 lines) in the package.
|
||||||
- `@deepseek-ai/dsh-subagent-process` — a pure library (the `subagent-inprocess` precedent) extracting what `dsh-subagent-acp` already carries and both new backends need: the credential env scrub (`SENSITIVE_ENV_PATTERN`/`buildChildEnv`), the EOF → SIGTERM → SIGKILL dispose ladder, and new isolated-config-dir helpers (`mkdtemp` create, best-effort remove). The ACP backend migrates onto it; `bash-local`'s sibling copy is left alone to bound the change.
|
- `@deepseek-ai/dsh-subagent-process` — a pure library (the `subagent-inprocess` precedent) extracting what `dsh-subagent-acp` already carries and both new backends need: the credential env scrub (`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)
|
## Verified interface facts (pinned versions)
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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.
|
`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
|
## 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
|
## Acceptance criteria
|
||||||
|
|
||||||
@@ -35,4 +35,4 @@ The config-driven path must first settle its currently hidden resume-or-create p
|
|||||||
|
|
||||||
## Risks
|
## 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.
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import { join } from 'node:path'
|
|||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import LlmService from '@deepseek-ai/dsh-llm'
|
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 type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry, { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
|
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 AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
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 () => {
|
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-'))
|
workdir = await mkdtemp(join(tmpdir(), 'dsh-code-mode-e2e-'))
|
||||||
ctx = await codeModeHarness(workdir)
|
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([{
|
agent.send([{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { tmpdir } from 'node:os'
|
|||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
import type { Context } from 'cordis'
|
import type { Context } from 'cordis'
|
||||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
|
||||||
import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts'
|
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
|
* 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)
|
expect(before.status).not.toBe(0)
|
||||||
|
|
||||||
ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT })
|
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([{
|
agent.send([{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { tmpdir } from 'node:os'
|
|||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
import type { Context } from 'cordis'
|
import type { Context } from 'cordis'
|
||||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
|
||||||
import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts'
|
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
|
* 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'),
|
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([{
|
agent.send([{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { tmpdir } from 'node:os'
|
|||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
import type { Context } from 'cordis'
|
import type { Context } from 'cordis'
|
||||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
|
||||||
import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts'
|
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
|
* 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 () => {
|
it('runs a bash command on request and reports its output', async () => {
|
||||||
workdir = await mkdtemp(join(tmpdir(), 'dsh-full-loop-e2e-'))
|
workdir = await mkdtemp(join(tmpdir(), 'dsh-full-loop-e2e-'))
|
||||||
ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT })
|
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.' }])
|
agent.send([{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { join } from 'node:path'
|
|||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
import type { Context } from 'cordis'
|
import type { Context } from 'cordis'
|
||||||
import type { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
import type { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
|
||||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts'
|
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.
|
// log on disk survives.
|
||||||
ctx = await codingHarness(process.cwd(), { persona: SYSTEM_PROMPT, persistenceRoot: root })
|
ctx = await codingHarness(process.cwd(), { persona: SYSTEM_PROMPT, persistenceRoot: root })
|
||||||
const first = (await ctx.agents.create({
|
const first = (await ctx.agents.create({
|
||||||
agentId: AgentId('resume-1'),
|
|
||||||
sessionId: SESSION_ID,
|
sessionId: SESSION_ID,
|
||||||
agentOptions: { model: 'deepseek-v4-flash' },
|
agentOptions: { model: 'deepseek-v4-flash' },
|
||||||
})).agent as ReactLoopAgent
|
})).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.
|
// run 1's exchange as conversation history.
|
||||||
ctx = await codingHarness(process.cwd(), { persona: SYSTEM_PROMPT, persistenceRoot: root })
|
ctx = await codingHarness(process.cwd(), { persona: SYSTEM_PROMPT, persistenceRoot: root })
|
||||||
const resumed = (await ctx.agents.resume({
|
const resumed = (await ctx.agents.resume({
|
||||||
agentId: AgentId('resume-2'),
|
|
||||||
resumeSessionId: SESSION_ID,
|
resumeSessionId: SESSION_ID,
|
||||||
agentOptions: { model: 'deepseek-v4-flash' },
|
agentOptions: { model: 'deepseek-v4-flash' },
|
||||||
})).agent as ReactLoopAgent
|
})).agent as ReactLoopAgent
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { tmpdir } from 'node:os'
|
|||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
import type { Context } from 'cordis'
|
import type { Context } from 'cordis'
|
||||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
|
||||||
import { codingHarness, TODO_SYSTEM_PROMPT, waitForIdle } from './harness.ts'
|
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
|
* 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 () => {
|
it('appends a todo/write event with the model-produced task list', async () => {
|
||||||
workdir = await mkdtemp(join(tmpdir(), 'dsh-todo-write-e2e-'))
|
workdir = await mkdtemp(join(tmpdir(), 'dsh-todo-write-e2e-'))
|
||||||
ctx = await codingHarness(workdir, { persona: TODO_SYSTEM_PROMPT })
|
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:
|
agent.send([{ type: 'text', text:
|
||||||
'Use the todo_write tool to record a plan of exactly two steps: first '
|
'Use the todo_write tool to record a plan of exactly two steps: first '
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import type { Context } from 'cordis'
|
import type { Context } from 'cordis'
|
||||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
|
||||||
import { cordisHarness, waitForIdle } from './harness.ts'
|
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
|
* 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 () => {
|
it('mounts a status listener whose tagged output actually fires, then unmounts it', async () => {
|
||||||
ctx = await cordisHarness()
|
ctx = await cordisHarness()
|
||||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
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([{
|
agent.send([{
|
||||||
type: 'text',
|
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 () => {
|
it('builds itself a reverse_text tool and actually calls it', async () => {
|
||||||
ctx = await cordisHarness()
|
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([{
|
agent.send([{
|
||||||
type: 'text',
|
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 () => {
|
it('composes two mounts through provide/inject, and unmounting the provider parks the consumer', async () => {
|
||||||
ctx = await cordisHarness()
|
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([{
|
agent.send([{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import LlmService from '@deepseek-ai/dsh-llm'
|
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 type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
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 AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||||
import { BashTaskId } from '@deepseek-ai/dsh-bash'
|
import { BashTaskId } from '@deepseek-ai/dsh-bash'
|
||||||
@@ -74,7 +75,7 @@ describe('bash tool through the agent loop', () => {
|
|||||||
textResponse('The command printed integration-ok.'),
|
textResponse('The command printed integration-ok.'),
|
||||||
])
|
])
|
||||||
const ctx = await harness(adapter)
|
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' }])
|
agent.send([{ type: 'text', text: 'run echo integration-ok' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
@@ -106,7 +107,7 @@ describe('bash tool through the agent loop', () => {
|
|||||||
textResponse('It failed with code 9.'),
|
textResponse('It failed with code 9.'),
|
||||||
])
|
])
|
||||||
const ctx = await harness(adapter)
|
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' }])
|
agent.send([{ type: 'text', text: 'run exit 9' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
@@ -128,7 +129,7 @@ describe('bash tool through the agent loop', () => {
|
|||||||
let taskId = ''
|
let taskId = ''
|
||||||
|
|
||||||
const ctx = await harness(adapter)
|
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
|
// Capture the generated id so the deterministic fixture is checked against
|
||||||
// the real executor instead of silently assuming it.
|
// the real executor instead of silently assuming it.
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ async function setup() {
|
|||||||
const fakeAgentDisposers = new Map<Context, (() => Promise<void> | void)[]>()
|
const fakeAgentDisposers = new Map<Context, (() => Promise<void> | void)[]>()
|
||||||
function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void): Agent {
|
function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void): Agent {
|
||||||
// The registry KEY (agent.id) is deliberately DIFFERENT from the session
|
// 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
|
// 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
|
// `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
|
// the test fail if a regression matched on the wrong field (a same-value fake
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import { Context } from 'cordis'
|
|||||||
import LlmService from '@deepseek-ai/dsh-llm'
|
import LlmService from '@deepseek-ai/dsh-llm'
|
||||||
import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||||
import { CallId, LlmAdapter } 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 { isToolPairingBalanced } from '@deepseek-ai/dsh-session'
|
||||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
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 AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||||
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
|
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 () => {
|
it('the head checkpoint the loop lands is a balanced cut on both sides', async () => {
|
||||||
const { ctx } = await harness(8)
|
const { ctx } = await harness(8)
|
||||||
try {
|
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' }])
|
agent.send([{ type: 'text', text: 'do a long multi-step task' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
|||||||
key: 'agentLoop',
|
key: 'agentLoop',
|
||||||
summary: 'Concrete ReactLoopAgent factory and driver service.',
|
summary: 'Concrete ReactLoopAgent factory and driver service.',
|
||||||
methods: [
|
methods: [
|
||||||
'create(id: AgentId, options: AgentOptions = {}, meta: Pick<SessionHeader, \'cwd\'> = {}): ReactLoopAgent',
|
'create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, \'cwd\'> = {}): ReactLoopAgent',
|
||||||
'async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>',
|
'async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>',
|
||||||
'async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>',
|
'async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>',
|
||||||
],
|
],
|
||||||
@@ -71,7 +71,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
|||||||
'register(agent: Agent): () => void',
|
'register(agent: Agent): () => void',
|
||||||
'enter(agent: Agent): () => void',
|
'enter(agent: Agent): () => void',
|
||||||
'announce(agent: Agent): void',
|
'announce(agent: Agent): void',
|
||||||
'get(id: AgentId): Agent | undefined',
|
'get(id: SessionId): Agent | undefined',
|
||||||
'list(): Agent[]',
|
'list(): Agent[]',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -488,7 +488,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
|||||||
export const TYPE_API: readonly TypeApiEntry[] = [
|
export const TYPE_API: readonly TypeApiEntry[] = [
|
||||||
{
|
{
|
||||||
name: 'Agent',
|
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<void>;\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<void>;\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'AgentFactory',
|
name: 'AgentFactory',
|
||||||
@@ -498,10 +498,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
|||||||
name: 'AgentHandle',
|
name: 'AgentHandle',
|
||||||
declaration: 'export interface AgentHandle {\n agent: Agent;\n dispose(): Promise<void>;\n}',
|
declaration: 'export interface AgentHandle {\n agent: Agent;\n dispose(): Promise<void>;\n}',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'AgentId',
|
|
||||||
declaration: 'export type AgentId = Branded<\'AgentId\'>;',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'AgentOptions',
|
name: 'AgentOptions',
|
||||||
declaration: 'export interface AgentOptions {\n model?: string;\n}',
|
declaration: 'export interface AgentOptions {\n model?: string;\n}',
|
||||||
@@ -644,7 +640,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'CreateAgentOptions',
|
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> | 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> | void;\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'CreateSessionOptions',
|
name: 'CreateSessionOptions',
|
||||||
@@ -756,7 +752,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'ResumeAgentOptions',
|
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> | 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> | void;\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'SandboxEnforcement',
|
name: 'SandboxEnforcement',
|
||||||
@@ -868,7 +864,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'SubagentRun',
|
name: 'SubagentRun',
|
||||||
declaration: 'export interface SubagentRun {\n readonly id: AgentId;\n readonly result: Promise<SubagentResult>;\n dispose(): Promise<void>;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): Promise<SubagentRun>;\n}',
|
declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly result: Promise<SubagentResult>;\n dispose(): Promise<void>;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): Promise<SubagentRun>;\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'SubagentStartRequest',
|
name: 'SubagentStartRequest',
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import LlmService from '@deepseek-ai/dsh-llm'
|
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 SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
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 AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||||
import * as ToolCordis from '../src/index.ts'
|
import * as ToolCordis from '../src/index.ts'
|
||||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.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.'),
|
textResponse('Done.'),
|
||||||
])
|
])
|
||||||
const ctx = await harness(adapter)
|
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' }])
|
agent.send([{ type: 'text', text: 'give yourself reverse_text, use it, clean up' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import type { Config } from '@deepseek-ai/dsh-agent-core'
|
|||||||
// so validation and defaulting can never drift from the owners.
|
// 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
|
## Why a code bundle, not a shared YAML include
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import { Context } from 'cordis'
|
|||||||
import Loader from '@cordisjs/plugin-loader'
|
import Loader from '@cordisjs/plugin-loader'
|
||||||
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
||||||
import * as agentCore from '../src/index.ts'
|
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 type { Message } from '@deepseek-ai/dsh-llm'
|
||||||
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||||
|
|
||||||
async function composePrefix(ctx: Context, cwd: string): Promise<Message[]> {
|
async function composePrefix(ctx: Context, cwd: string): Promise<Message[]> {
|
||||||
const agent = { session: { header: { cwd } } } as unknown as Agent
|
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 () => {
|
it('defaults the agents list to empty (no pre-created agents)', async () => {
|
||||||
const ctx = await mount()
|
const ctx = await mount()
|
||||||
expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined()
|
expect(ctx.get('agents')?.get(SessionId('main'))).toBeUndefined()
|
||||||
await ctx.fiber.dispose()
|
await ctx.fiber.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('forwards a pre-created agent to the loop and the persona to system-prompt', async () => {
|
it('forwards a pre-created agent to the loop and the persona to system-prompt', async () => {
|
||||||
const ctx = await mount({
|
const ctx = await mount({
|
||||||
agents: [{ id: AgentId('main'), model: 'mock' }],
|
agents: [{ id: 'main', model: 'mock' }],
|
||||||
persona: 'You are main.',
|
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()
|
const assembly = await ctx.get('systemPrompt')!.assemble()
|
||||||
expect(assembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You are main.')
|
expect(assembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You are main.')
|
||||||
await ctx.fiber.dispose()
|
await ctx.fiber.dispose()
|
||||||
|
|||||||
@@ -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.
|
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-<uuid>` 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-<uuid>` 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):
|
`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<AgentHandle>` — 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.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — 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({ agentId, resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — 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.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — 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.
|
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
|
```ts
|
||||||
interface Config {
|
interface Config {
|
||||||
agents: Array<{
|
agents: Array<{
|
||||||
id: string // required
|
id: string // required stable label; prefixes fresh combined ids
|
||||||
model?: string
|
model?: string
|
||||||
resumeSessionId?: string // load this persisted session instead of creating one
|
resumeSessionId?: string // load this persisted session instead of creating one
|
||||||
cwd?: string // optional workspace cwd for the fresh session
|
cwd?: string // optional workspace cwd for the fresh session
|
||||||
|
|||||||
@@ -8,11 +8,11 @@
|
|||||||
|
|
||||||
import type { Context } from 'cordis'
|
import type { Context } from 'cordis'
|
||||||
import { agentEvents } from '@deepseek-ai/dsh-agent'
|
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 type { Agent } from '@deepseek-ai/dsh-agent'
|
||||||
import { deepFreeze } from '@deepseek-ai/dsh-llm'
|
import { deepFreeze } from '@deepseek-ai/dsh-llm'
|
||||||
import type { ContentBlock, MessageSource } 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 { Inbox, type InboxMessage } from './inbox.ts'
|
||||||
import { isTurnOpen, lastTurnNumber, runLoop } from './loop.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.
|
* @returns the agent and closures bound only to that exact instance.
|
||||||
*/
|
*/
|
||||||
export function prepareReactLoopAgent(
|
export function prepareReactLoopAgent(
|
||||||
ctx: Context, id: AgentId, options: AgentOptions, session: Session,
|
ctx: Context, id: SessionId, options: AgentOptions, session: Session,
|
||||||
): PreparedReactLoopAgent {
|
): PreparedReactLoopAgent {
|
||||||
if (claimedDriverSessions.has(session)) {
|
if (claimedDriverSessions.has(session)) {
|
||||||
throw new Error(`session "${session.id}" already has a concrete agent driver`)
|
throw new Error(`session "${session.id}" already has a concrete agent driver`)
|
||||||
@@ -163,7 +163,7 @@ export class ReactLoopAgent implements Agent {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private loopCtx: Context,
|
private loopCtx: Context,
|
||||||
public readonly id: AgentId,
|
public readonly id: SessionId,
|
||||||
public readonly options: AgentOptions,
|
public readonly options: AgentOptions,
|
||||||
public readonly session: Session,
|
public readonly session: Session,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { agentEvents } from '@deepseek-ai/dsh-agent'
|
|||||||
import type {
|
import type {
|
||||||
AgentFactory,
|
AgentFactory,
|
||||||
AgentHandle,
|
AgentHandle,
|
||||||
AgentId,
|
|
||||||
AgentOptions,
|
AgentOptions,
|
||||||
CreateAgentOptions,
|
CreateAgentOptions,
|
||||||
ResumeAgentOptions,
|
ResumeAgentOptions,
|
||||||
@@ -68,7 +67,7 @@ class FactoryOwnership {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Build the public cancellation error while preserving a caller-supplied cause. */
|
/** 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
|
if (signal.reason instanceof Error) return signal.reason
|
||||||
return new Error(`agent "${id}" creation aborted`, { cause: signal.reason })
|
return new Error(`agent "${id}" creation aborted`, { cause: signal.reason })
|
||||||
}
|
}
|
||||||
@@ -108,7 +107,7 @@ class AgentCreationTransaction {
|
|||||||
private readonly loopCtx: Context,
|
private readonly loopCtx: Context,
|
||||||
private readonly ownerCtx: Context,
|
private readonly ownerCtx: Context,
|
||||||
private readonly ownership: FactoryOwnership,
|
private readonly ownership: FactoryOwnership,
|
||||||
readonly id: AgentId,
|
readonly id: SessionId,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
) {
|
) {
|
||||||
ownerCtx.fiber.assertActive()
|
ownerCtx.fiber.assertActive()
|
||||||
@@ -325,8 +324,8 @@ declare module 'cordis' {
|
|||||||
export interface Config {
|
export interface Config {
|
||||||
/** Agents created or resumed at plugin startup. */
|
/** Agents created or resumed at plugin startup. */
|
||||||
agents: (AgentOptions & {
|
agents: (AgentOptions & {
|
||||||
/** Registry identity for the live agent. */
|
/** Stable config label used in logs and as the fresh combined-id prefix. */
|
||||||
id: AgentId
|
id: string
|
||||||
/** Optional workspace for a fresh session. */
|
/** Optional workspace for a fresh session. */
|
||||||
cwd?: string
|
cwd?: string
|
||||||
/** Persisted session to resume instead of creating a fresh session. */
|
/** 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) {
|
for (const { id, cwd, resumeSessionId, ...options } of config.agents) {
|
||||||
if (resumeSessionId === undefined || resumeSessionId === '') {
|
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
|
continue
|
||||||
}
|
}
|
||||||
ctx.effect(() => {
|
ctx.effect(() => {
|
||||||
const fiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => {
|
const fiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => {
|
||||||
void this.resumeWith(ctx, childCtx.sessionPersistence, {
|
void this.resumeWith(ctx, childCtx.sessionPersistence, {
|
||||||
agentId: id,
|
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
agentOptions: options,
|
agentOptions: options,
|
||||||
}).catch((error: unknown) => {
|
}).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.
|
* Create an agent and session under one caller-supplied identity, owned by
|
||||||
* Constructor-driven config calls use the loop fiber itself.
|
* the accessing fiber. Constructor-driven config calls mint a fresh combined
|
||||||
* @param id - agent registry id.
|
* id before entering this boundary.
|
||||||
|
* @param id - shared agent/session identity.
|
||||||
* @param options - concrete loop options.
|
* @param options - concrete loop options.
|
||||||
* @param meta - optional fresh-session workspace metadata.
|
* @param meta - optional fresh-session workspace metadata.
|
||||||
* @returns the published running agent.
|
* @returns the published running agent.
|
||||||
*/
|
*/
|
||||||
create(id: AgentId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): ReactLoopAgent {
|
create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): ReactLoopAgent {
|
||||||
const loopCtx = this.runtime.ctx
|
const loopCtx = this.runtime.ctx
|
||||||
const transaction = new AgentCreationTransaction(loopCtx, this.ctx, this.ownership, id)
|
const transaction = new AgentCreationTransaction(loopCtx, this.ctx, this.ownership, id)
|
||||||
try {
|
try {
|
||||||
const sessionId = SessionId(`${id}-session-${randomUUID()}`)
|
const session = loopCtx.sessions.prepare(id, { meta })
|
||||||
const session = loopCtx.sessions.prepare(sessionId, { meta })
|
|
||||||
const agent = transaction.prepare(options, session)
|
const agent = transaction.prepare(options, session)
|
||||||
transaction.publish('startup')
|
transaction.publish('startup')
|
||||||
return agent
|
return agent
|
||||||
@@ -417,7 +416,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
|||||||
this.runtime.ctx,
|
this.runtime.ctx,
|
||||||
ownerCtx,
|
ownerCtx,
|
||||||
this.ownership,
|
this.ownership,
|
||||||
options.agentId,
|
options.sessionId,
|
||||||
options.signal,
|
options.signal,
|
||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
@@ -461,7 +460,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
|||||||
this.runtime.ctx,
|
this.runtime.ctx,
|
||||||
ownerCtx,
|
ownerCtx,
|
||||||
this.ownership,
|
this.ownership,
|
||||||
options.agentId,
|
options.resumeSessionId,
|
||||||
options.signal,
|
options.signal,
|
||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
|
||||||
import LlmService from '@deepseek-ai/dsh-llm'
|
import LlmService from '@deepseek-ai/dsh-llm'
|
||||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
@@ -53,10 +52,10 @@ describe('ReactLoopAgent', () => {
|
|||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(SessionStore)
|
await ctx.plugin(SessionStore)
|
||||||
const session = ctx.sessions.create(SessionId('exclusive-driver'))
|
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(() => 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')
|
.toThrow('already has a concrete agent driver')
|
||||||
|
|
||||||
await prepared.dispose()
|
await prepared.dispose()
|
||||||
@@ -66,11 +65,11 @@ describe('ReactLoopAgent', () => {
|
|||||||
it('borrows caller options and binds its scoped context exactly once', async () => {
|
it('borrows caller options and binds its scoped context exactly once', async () => {
|
||||||
const ctx = await harness(new MockAdapter([textResponse('unused')]))
|
const ctx = await harness(new MockAdapter([textResponse('unused')]))
|
||||||
const options = { model: 'mock' }
|
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.options).toBe(options)
|
||||||
expect(agent.id).toBe('owned-bindings')
|
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/)
|
expect(() => { bindReactLoopAgentContext(agent, new Context()) }).toThrow(/context is already bound/)
|
||||||
|
|
||||||
await ctx.fiber.dispose()
|
await ctx.fiber.dispose()
|
||||||
@@ -81,7 +80,7 @@ describe('ReactLoopAgent', () => {
|
|||||||
const ctx = await harness(adapter)
|
const ctx = await harness(adapter)
|
||||||
let agent!: ReactLoopAgent
|
let agent!: ReactLoopAgent
|
||||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
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'] }))
|
}, { inject: ['agentLoop'] }))
|
||||||
send(agent, 'go')
|
send(agent, 'go')
|
||||||
await new Promise(r => setTimeout(r, 30))
|
await new Promise(r => setTimeout(r, 30))
|
||||||
@@ -96,7 +95,7 @@ describe('ReactLoopAgent', () => {
|
|||||||
const ctx = await harness(adapter)
|
const ctx = await harness(adapter)
|
||||||
let agent!: ReactLoopAgent
|
let agent!: ReactLoopAgent
|
||||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
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'] }))
|
}, { inject: ['agentLoop'] }))
|
||||||
send(agent, 'go')
|
send(agent, 'go')
|
||||||
await new Promise(r => setTimeout(r, 30))
|
await new Promise(r => setTimeout(r, 30))
|
||||||
@@ -111,7 +110,7 @@ describe('ReactLoopAgent', () => {
|
|||||||
const ctx = await harness(adapter)
|
const ctx = await harness(adapter)
|
||||||
let agent!: ReactLoopAgent
|
let agent!: ReactLoopAgent
|
||||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
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'] }))
|
}, { inject: ['agentLoop'] }))
|
||||||
send(agent, 'go')
|
send(agent, 'go')
|
||||||
await new Promise(r => setTimeout(r, 30))
|
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 () => {
|
it('inject() decides enclosure from the LOG (open turn), not agent status', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(adapter)
|
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
|
// 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
|
// 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.
|
// A persistence-like listener whose flush rejects.
|
||||||
ctx.on('session/flush', () => { throw new Error('disk gone') })
|
ctx.on('session/flush', () => { throw new Error('disk gone') })
|
||||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
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
|
// inject() is synchronous and fires a fire-and-forget flush; a rejecting
|
||||||
// flush must be contained (logged), never thrown into the caller.
|
// 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 () => {
|
it('idle inject() closes its one-shot turn AND still checkpoints even if the append throws', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(adapter)
|
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
|
let flushes = 0
|
||||||
ctx.on('session/flush', () => { flushes += 1 })
|
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 () => {
|
it('idle inject() still checkpoints when a listener throws on the synthetic turn/end', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(adapter)
|
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
|
let flushes = 0
|
||||||
ctx.on('session/flush', () => { flushes += 1 })
|
ctx.on('session/flush', () => { flushes += 1 })
|
||||||
// Session contains a throwing post-commit turn/end observer. The accepted
|
// 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.
|
// A non-Error rejection exercises the String() normalization branch.
|
||||||
ctx.on('session/flush', () => { throw 'disk gone' })
|
ctx.on('session/flush', () => { throw 'disk gone' })
|
||||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
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 }[] = []
|
const errors: { turn: number; step: number; message: string }[] = []
|
||||||
ctx.on('agent/error', (_a, turn, step, error) => void errors.push({ turn, step, message: error.message }))
|
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 () => {
|
it('idle inject() with a non-serializable source opens no turn (nothing to close)', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(adapter)
|
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
|
// A non-serializable source makes the turn/start append throw BEFORE the
|
||||||
// event is pushed (Session.append validates before push), so NO turn opens.
|
// 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 () => {
|
it('steer() when idle falls through to send() and starts a turn', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(adapter)
|
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
|
// steer while idle delegates to send
|
||||||
agent.steer([{ type: 'text', text: 'steer idle' }], { source: { kind: 'plugin', plugin: 'test' } })
|
agent.steer([{ type: 'text', text: 'steer idle' }], { source: { kind: 'plugin', plugin: 'test' } })
|
||||||
@@ -258,7 +257,7 @@ describe('ReactLoopAgent', () => {
|
|||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(SessionStore)
|
await ctx.plugin(SessionStore)
|
||||||
const session = ctx.sessions.create(SessionId('test'))
|
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
|
const { agent } = prepared
|
||||||
|
|
||||||
// Start the loop to get the disposer; the agent waits for messages
|
// Start the loop to get the disposer; the agent waits for messages
|
||||||
@@ -280,7 +279,7 @@ describe('ReactLoopAgent', () => {
|
|||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(SessionStore)
|
await ctx.plugin(SessionStore)
|
||||||
const session = ctx.sessions.create(SessionId('pre-start-dispose'))
|
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()
|
await prepared.dispose()
|
||||||
expect(prepared.agent.status).toBe('disposed')
|
expect(prepared.agent.status).toBe('disposed')
|
||||||
@@ -294,7 +293,7 @@ describe('ReactLoopAgent', () => {
|
|||||||
it('setting the same status does not emit agent/status again', async () => {
|
it('setting the same status does not emit agent/status again', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(adapter)
|
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[] = []
|
const statuses: string[] = []
|
||||||
ctx.on('agent/status', (subject, status) => {
|
ctx.on('agent/status', (subject, status) => {
|
||||||
@@ -313,7 +312,7 @@ describe('ReactLoopAgent', () => {
|
|||||||
it('whenIdle() resolves immediately when the agent is not running', async () => {
|
it('whenIdle() resolves immediately when the agent is not running', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(adapter)
|
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
|
// Fresh agent is idle — whenIdle() takes the not-running fast path and
|
||||||
// resolves without subscribing. await must not hang.
|
// 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 () => {
|
it('whenIdle() waits for queued work that has not flipped status yet', async () => {
|
||||||
const adapter = new MockAdapter(['hang'])
|
const adapter = new MockAdapter(['hang'])
|
||||||
const ctx = await harness(adapter)
|
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')
|
send(agent, 'queued')
|
||||||
let settled = false
|
let settled = false
|
||||||
@@ -342,8 +341,8 @@ describe('ReactLoopAgent', () => {
|
|||||||
it('whenIdle() awaits the running→idle transition, ignoring other subjects/running events', async () => {
|
it('whenIdle() awaits the running→idle transition, ignoring other subjects/running events', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('ok'), textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok'), textResponse('ok')])
|
||||||
const ctx = await harness(adapter)
|
const ctx = await harness(adapter)
|
||||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
|
||||||
const other = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' })
|
const other = ctx.agentLoop.create(SessionId('a2'), { model: 'mock' })
|
||||||
|
|
||||||
// Drive `agent` into `running`, then await whenIdle() — it subscribes to
|
// Drive `agent` into `running`, then await whenIdle() — it subscribes to
|
||||||
// agent/status and resolves on the first transition out of running.
|
// agent/status and resolves on the first transition out of running.
|
||||||
@@ -379,7 +378,7 @@ describe('ReactLoopAgent', () => {
|
|||||||
const adapter = new MockAdapter(['hang'])
|
const adapter = new MockAdapter(['hang'])
|
||||||
ctx.llm.registerAdapter(['mock'], adapter)
|
ctx.llm.registerAdapter(['mock'], adapter)
|
||||||
const session = ctx.sessions.create(SessionId('bare'))
|
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
|
const { agent } = prepared
|
||||||
prepared.markPublished()
|
prepared.markPublished()
|
||||||
const dispose = prepared.startDriver()
|
const dispose = prepared.startDriver()
|
||||||
@@ -404,7 +403,7 @@ describe('ReactLoopAgent', () => {
|
|||||||
const ctx = await harness(adapter)
|
const ctx = await harness(adapter)
|
||||||
let agent!: ReactLoopAgent
|
let agent!: ReactLoopAgent
|
||||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
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'] }))
|
}, { inject: ['agentLoop'] }))
|
||||||
send(agent, 'go')
|
send(agent, 'go')
|
||||||
await new Promise(r => setTimeout(r, 30))
|
await new Promise(r => setTimeout(r, 30))
|
||||||
@@ -425,7 +424,7 @@ describe('ReactLoopAgent', () => {
|
|||||||
const ctx = await harness(adapter)
|
const ctx = await harness(adapter)
|
||||||
let agent!: ReactLoopAgent
|
let agent!: ReactLoopAgent
|
||||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
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'] }))
|
}, { inject: ['agentLoop'] }))
|
||||||
send(agent, 'go')
|
send(agent, 'go')
|
||||||
await new Promise(r => setTimeout(r, 30))
|
await new Promise(r => setTimeout(r, 30))
|
||||||
@@ -446,7 +445,7 @@ describe('ReactLoopAgent', () => {
|
|||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(adapter)
|
const ctx = await harness(adapter)
|
||||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
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) => {
|
ctx.on('agent/status', (_subject, status) => {
|
||||||
if (status === 'running') throw new Error('bad running listener')
|
if (status === 'running') throw new Error('bad running listener')
|
||||||
})
|
})
|
||||||
@@ -464,7 +463,7 @@ describe('ReactLoopAgent', () => {
|
|||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(adapter)
|
const ctx = await harness(adapter)
|
||||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
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) => {
|
ctx.on('agent/status', (_subject, status) => {
|
||||||
if (status === 'idle') throw new Error('bad idle listener')
|
if (status === 'idle') throw new Error('bad idle listener')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import LlmService, { type Message } from '@deepseek-ai/dsh-llm'
|
|||||||
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
|
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
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 AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
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 () => {
|
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 adapter = new MockAdapter([textResponse('reply')])
|
||||||
const ctx = await harness(adapter)
|
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
|
// 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.
|
// 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 () => {
|
it('pre-step cancel drops the about-to-start turn (no turn is opened)', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('should not run')])
|
const adapter = new MockAdapter([textResponse('should not run')])
|
||||||
const ctx = await harness(adapter)
|
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
|
// send() queues synchronously (status still idle, loop microtask not yet
|
||||||
// resumed). Cancel in that pre-step window: the queued turn must not run.
|
// 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 () => {
|
it('a whenIdle() waiter registered BEFORE a pre-step cancel resolves (F1 hang guard)', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('x')])
|
const adapter = new MockAdapter([textResponse('x')])
|
||||||
const ctx = await harness(adapter)
|
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
|
// 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.
|
// (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 () => {
|
it('cancel() mid-step aborts the in-flight model call; the turn ends aborted', async () => {
|
||||||
const adapter = new MockAdapter(['hang'])
|
const adapter = new MockAdapter(['hang'])
|
||||||
const ctx = await harness(adapter)
|
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[] = []
|
const reasons: TurnEndReason[] = []
|
||||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
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 () => {
|
it('cancel() with no reason defaults to "cancelled" when aborting an in-flight step', async () => {
|
||||||
const adapter = new MockAdapter(['hang'])
|
const adapter = new MockAdapter(['hang'])
|
||||||
const ctx = await harness(adapter)
|
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[] = []
|
const reasons: TurnEndReason[] = []
|
||||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
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 () => {
|
it('a prompt sent AFTER a cancelled turn settles runs normally (marker reset)', async () => {
|
||||||
const adapter = new MockAdapter(['hang', textResponse('second reply')])
|
const adapter = new MockAdapter(['hang', textResponse('second reply')])
|
||||||
const ctx = await harness(adapter)
|
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.
|
// First turn hangs; cancel it mid-step.
|
||||||
send(agent, 'first')
|
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 () => {
|
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 adapter = new MockAdapter([textResponse('should not stream')])
|
||||||
const ctx = await harness(adapter)
|
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
|
// 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
|
// 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)
|
ctx.llm.registerAdapter(['mock'], adapter)
|
||||||
|
|
||||||
const handle = await ctx.agents.create({
|
const handle = await ctx.agents.create({
|
||||||
agentId: AgentId('a-dispose-prefix'),
|
|
||||||
sessionId: SessionId('dispose-prefix-session'),
|
sessionId: SessionId('dispose-prefix-session'),
|
||||||
agentOptions: { model: 'mock' },
|
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 () => {
|
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 adapter = new MockAdapter([textResponse('reply')])
|
||||||
const ctx = await harness(adapter)
|
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
|
// The first composition is interrupted mid-waterfall and — like an
|
||||||
// abort-aware listener bailing on a firing signal — contributes nothing.
|
// 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 () => {
|
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 adapter = new MockAdapter([textResponse('should not stream')])
|
||||||
const ctx = await harness(adapter)
|
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
|
// A turn/start listener fires right after turn/start is appended, BEFORE any
|
||||||
// AbortController is installed for the step. Cancelling there must still drop
|
// 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 () => {
|
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 adapter = new MockAdapter([textResponse('should not stream')])
|
||||||
const ctx = await harness(adapter)
|
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
|
// 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
|
// (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)
|
ctx.llm.registerAdapter(['mock'], adapter)
|
||||||
|
|
||||||
const handle = await ctx.agents.create({
|
const handle = await ctx.agents.create({
|
||||||
agentId: AgentId('a-dispose-step-start'),
|
|
||||||
sessionId: SessionId('dispose-step-start-session'),
|
sessionId: SessionId('dispose-step-start-session'),
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
})
|
})
|
||||||
@@ -366,7 +365,7 @@ describe('Agent.cancel()', () => {
|
|||||||
// `aborted` and run NO second step.
|
// `aborted` and run NO second step.
|
||||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||||
const ctx = await harness(adapter)
|
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
|
let steps = 0
|
||||||
const reasons: TurnEndReason[] = []
|
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 () => {
|
it('cancel from a synchronous agent/status(running) listener drops the turn (window 2)', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('should not run')])
|
const adapter = new MockAdapter([textResponse('should not run')])
|
||||||
const ctx = await harness(adapter)
|
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
|
// setStatus('running') emits agent/status SYNCHRONOUSLY, so a running
|
||||||
// listener can cancel in the gap between the loop's pre-step check and
|
// 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.
|
// so whenIdle() resolves on the replacement turn's running→idle, not before.
|
||||||
const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')])
|
const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')])
|
||||||
const ctx = await harness(adapter)
|
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
|
let replaced = false
|
||||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
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.
|
// settle (the quiescence contract), not resolve before B's first event.
|
||||||
const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')])
|
const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')])
|
||||||
const ctx = await harness(adapter)
|
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)
|
send(agent, 'A') // queues A (status still idle, loop microtask pending)
|
||||||
const idle = agent.whenIdle() // registers a waiter (idle + hasQueued → no fast path)
|
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 () => {
|
it("cancel clears the turn's steering — it is not re-enqueued as a fresh turn", async () => {
|
||||||
const adapter = new MockAdapter(['hang'])
|
const adapter = new MockAdapter(['hang'])
|
||||||
const ctx = await harness(adapter)
|
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')
|
send(agent, 'go')
|
||||||
await new Promise(r => setTimeout(r, 30))
|
await new Promise(r => setTimeout(r, 30))
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import LlmService from '@deepseek-ai/dsh-llm'
|
|||||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
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 SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||||
@@ -32,7 +33,7 @@ describe('config-driven session id', () => {
|
|||||||
await ctx.plugin(ToolRegistry)
|
await ctx.plugin(ToolRegistry)
|
||||||
await ctx.plugin(AgentRegistry)
|
await ctx.plugin(AgentRegistry)
|
||||||
const loopFiber = await ctx.plugin(AgentLoop, {
|
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)')
|
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(SystemPrompt)
|
||||||
await ctx1.plugin(ToolRegistry)
|
await ctx1.plugin(ToolRegistry)
|
||||||
await ctx1.plugin(AgentRegistry)
|
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 })
|
await ctx1.plugin(SessionPersistenceJsonl, { root })
|
||||||
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg')]))
|
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(a1.session.id).toMatch(idPattern)
|
||||||
|
expect(ctx1.agents.get(SessionId('cfg'))).toBeUndefined()
|
||||||
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||||
await waitForIdle(ctx1, a1)
|
await waitForIdle(ctx1, a1)
|
||||||
await ctx1.fiber.dispose()
|
await ctx1.fiber.dispose()
|
||||||
@@ -70,10 +73,11 @@ describe('config-driven session id', () => {
|
|||||||
await ctx2.plugin(SystemPrompt)
|
await ctx2.plugin(SystemPrompt)
|
||||||
await ctx2.plugin(ToolRegistry)
|
await ctx2.plugin(ToolRegistry)
|
||||||
await ctx2.plugin(AgentRegistry)
|
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 })
|
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||||
ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg2')]))
|
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).toMatch(idPattern)
|
||||||
expect(a2.session.id).not.toBe(a1.session.id)
|
expect(a2.session.id).not.toBe(a1.session.id)
|
||||||
a2.send([{ type: 'text', text: 'q2' }], { source: { kind: 'user' } })
|
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(AgentLoop, { agents: [] })
|
||||||
await ctx1.plugin(SessionPersistenceJsonl, { root })
|
await ctx1.plugin(SessionPersistenceJsonl, { root })
|
||||||
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')]))
|
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' } })
|
a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
|
||||||
await waitForIdle(ctx1, a1)
|
await waitForIdle(ctx1, a1)
|
||||||
await ctx1.fiber.dispose()
|
await ctx1.fiber.dispose()
|
||||||
@@ -110,7 +114,7 @@ describe('config-driven session id', () => {
|
|||||||
await ctx2.plugin(SystemPrompt)
|
await ctx2.plugin(SystemPrompt)
|
||||||
await ctx2.plugin(ToolRegistry)
|
await ctx2.plugin(ToolRegistry)
|
||||||
await ctx2.plugin(AgentRegistry)
|
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 })
|
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||||
ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')]))
|
ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')]))
|
||||||
|
|
||||||
@@ -118,11 +122,12 @@ describe('config-driven session id', () => {
|
|||||||
let resumed: ReactLoopAgent | undefined
|
let resumed: ReactLoopAgent | undefined
|
||||||
for (let i = 0; i < 50 && !resumed; i++) {
|
for (let i = 0; i < 50 && !resumed; i++) {
|
||||||
await new Promise(r => setTimeout(r, 5))
|
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()
|
expect(resumed).toBeDefined()
|
||||||
// The live session id IS the resumed id (NOT a fresh ${id}-session-<uuid>),
|
// The live session id IS the resumed id (NOT a fresh ${id}-session-<uuid>),
|
||||||
// and the prior turn's user message is in the derived history.
|
// 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')
|
expect(resumed!.session.id).toBe('sticky-1')
|
||||||
const derived = resumed!.session.deriveMessages()
|
const derived = resumed!.session.deriveMessages()
|
||||||
expect(JSON.stringify(derived)).toContain('remember me')
|
expect(JSON.stringify(derived)).toContain('remember me')
|
||||||
@@ -138,16 +143,16 @@ describe('config-driven session id', () => {
|
|||||||
await ctx.plugin(SystemPrompt)
|
await ctx.plugin(SystemPrompt)
|
||||||
await ctx.plugin(ToolRegistry)
|
await ctx.plugin(ToolRegistry)
|
||||||
await ctx.plugin(AgentRegistry)
|
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')
|
const warn = vi.spyOn((ctx.agentLoop as unknown as { ctx: { logger: { warn: (...a: unknown[]) => void } } }).ctx.logger, 'warn')
|
||||||
.mockImplementation(() => undefined)
|
.mockImplementation(() => undefined)
|
||||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('x')]))
|
ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('x')]))
|
||||||
|
|
||||||
// The deferred resume fails (no such session on disk). It must be contained:
|
// 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))
|
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'))
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining('config-driven resume of "does-not-exist" failed'))
|
||||||
warn.mockRestore()
|
warn.mockRestore()
|
||||||
await ctx.fiber.dispose()
|
await ctx.fiber.dispose()
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import LlmService, { CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm'
|
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 type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
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 AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
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 () => {
|
it('rejects non-serializable content or source synchronously before notification or enqueue', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('turn 1')])
|
const adapter = new MockAdapter([textResponse('turn 1')])
|
||||||
const ctx = await harness(adapter)
|
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
|
let queued = 0
|
||||||
ctx.on('agent/queued', () => { queued += 1 })
|
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) }]
|
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')
|
send(agent, 'use tool')
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
@@ -113,7 +114,7 @@ describe('tool JSON parse', () => {
|
|||||||
return [{ type: 'text', text: 'ran with empty args' }]
|
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')
|
send(agent, 'use tool')
|
||||||
await waitForIdle(ctx, agent)
|
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 () => {
|
it('normalizes non-Error throws from pre-commit dispatch validation via the runLoop backstop', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(adapter)
|
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
|
let threwOnce = false
|
||||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
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 () => {
|
it('normalizes non-Error throws from agent/request waterfall via inline toError in runStep catch', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('irrelevant')])
|
const adapter = new MockAdapter([textResponse('irrelevant')])
|
||||||
const ctx = await harness(adapter)
|
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
|
let threwOnce = false
|
||||||
ctx.on('agent/request', async (_agent, _turn, _step, _options, _next) => {
|
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 () => {
|
it('errorData includes code when a coded error (LlmError) is thrown from a plugin', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('turn 1')])
|
const adapter = new MockAdapter([textResponse('turn 1')])
|
||||||
const ctx = await harness(adapter)
|
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
|
let threwOnce = false
|
||||||
ctx.on('agent/request', async (_agent, _turn, _step, _options, next) => {
|
ctx.on('agent/request', async (_agent, _turn, _step, _options, next) => {
|
||||||
@@ -214,7 +215,7 @@ describe('disposed vs aborted branching', () => {
|
|||||||
const ctx = await harness(adapter)
|
const ctx = await harness(adapter)
|
||||||
let agent!: ReactLoopAgent
|
let agent!: ReactLoopAgent
|
||||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
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'] }))
|
}, { inject: ['agentLoop'] }))
|
||||||
|
|
||||||
const reasons: TurnEndReason[] = []
|
const reasons: TurnEndReason[] = []
|
||||||
@@ -242,7 +243,7 @@ describe('structured tool error propagation (the runtime-validation RFC, part 2)
|
|||||||
textResponse('done'),
|
textResponse('done'),
|
||||||
])
|
])
|
||||||
const ctx = await harness(adapter)
|
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({
|
ctx.tools.register(defineTool({
|
||||||
name: 'boom',
|
name: 'boom',
|
||||||
description: 'always fails',
|
description: 'always fails',
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import LlmService, { CallId, type Message } from '@deepseek-ai/dsh-llm'
|
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 SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
|
import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
|
||||||
import AgentRegistry, {
|
import AgentRegistry, { type ContinuationDecision, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent'
|
||||||
AgentId,
|
|
||||||
type ContinuationDecision,
|
|
||||||
type PromptDecision,
|
|
||||||
type SessionStartSource,
|
|
||||||
} from '@deepseek-ai/dsh-agent'
|
|
||||||
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
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 () => {
|
it('allow (default via next) records the user/message unchanged', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(adapter)
|
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[] = []
|
const seen: string[] = []
|
||||||
ctx.on('agent/prompt-submit', async (_agent, content, _source, next) => {
|
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 () => {
|
it('allow with content REWRITES the prompt before it is recorded', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(adapter)
|
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<PromptDecision> =>
|
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
|
||||||
({ kind: 'allow', content: [{ type: 'text', text: 'REWRITTEN' }] }))
|
({ 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 () => {
|
it('allow with additionalContext injects a separate context/message into the turn', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(adapter)
|
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<PromptDecision> =>
|
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
|
||||||
({
|
({
|
||||||
@@ -127,7 +123,7 @@ describe('agent/prompt-submit', () => {
|
|||||||
// elsewhere; this asserts they see each other's effects on the same turn).
|
// elsewhere; this asserts they see each other's effects on the same turn).
|
||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(adapter)
|
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<PromptDecision> =>
|
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
|
||||||
({
|
({
|
||||||
@@ -157,7 +153,7 @@ describe('agent/prompt-submit', () => {
|
|||||||
it('block drops the (only) prompt → zero-step turn ends rejected, model never called', async () => {
|
it('block drops the (only) prompt → zero-step turn ends rejected, model never called', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('should not run')])
|
const adapter = new MockAdapter([textResponse('should not run')])
|
||||||
const ctx = await harness(adapter)
|
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<PromptDecision> =>
|
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
|
||||||
({ kind: 'block', reason: 'blocked by policy' }))
|
({ 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.
|
// vetoed prompt and its reason would vanish from the log entirely.
|
||||||
const adapter = new MockAdapter([textResponse('ran once')])
|
const adapter = new MockAdapter([textResponse('ran once')])
|
||||||
const ctx = await harness(adapter)
|
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<PromptDecision> => {
|
ctx.on('agent/prompt-submit', async (_agent, content, _source, next): Promise<PromptDecision> => {
|
||||||
const text = content.map(b => (b.type === 'text' ? b.text : '')).join('')
|
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 () => {
|
it('a throwing prompt-submit listener ends the turn balanced (error), loop survives', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('after')])
|
const adapter = new MockAdapter([textResponse('after')])
|
||||||
const ctx = await harness(adapter)
|
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
|
let threw = false
|
||||||
ctx.on('agent/prompt-submit', async () => {
|
ctx.on('agent/prompt-submit', async () => {
|
||||||
@@ -263,7 +259,7 @@ describe('agent/session-start', () => {
|
|||||||
const sources: SessionStartSource[] = []
|
const sources: SessionStartSource[] = []
|
||||||
ctx.on('agent/session-start', (_agent, source) => void sources.push(source))
|
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
|
// fires synchronously at create, before any turn
|
||||||
expect(sources).toEqual(['startup'])
|
expect(sources).toEqual(['startup'])
|
||||||
expect(events(agent).some(e => e.type === 'turn/start')).toBe(false)
|
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' } })
|
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')
|
send(agent, 'go')
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
@@ -300,8 +296,8 @@ describe('agent/session-start', () => {
|
|||||||
ctx.on('agent/session-start', () => { throw new Error('session-start hook broke') })
|
ctx.on('agent/session-start', () => { throw new Error('session-start hook broke') })
|
||||||
|
|
||||||
// create must not throw — the listener error is contained/logged
|
// create must not throw — the listener error is contained/logged
|
||||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
|
||||||
expect(agent.id).toBe(AgentId('a1'))
|
expect(agent.id).toBe(SessionId('a1'))
|
||||||
|
|
||||||
// and the agent still runs
|
// and the agent still runs
|
||||||
send(agent, 'go')
|
send(agent, 'go')
|
||||||
@@ -314,8 +310,8 @@ describe('agent/session-prefix', () => {
|
|||||||
it('dispatches to global and matching agent-scope listeners only', async () => {
|
it('dispatches to global and matching agent-scope listeners only', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('a done'), textResponse('b done')])
|
const adapter = new MockAdapter([textResponse('a done'), textResponse('b done')])
|
||||||
const ctx = await harness(adapter)
|
const ctx = await harness(adapter)
|
||||||
const agentA = ctx.agentLoop.create(AgentId('prefix-a'), { model: 'mock' })
|
const agentA = ctx.agentLoop.create(SessionId('prefix-a'), { model: 'mock' })
|
||||||
const agentB = ctx.agentLoop.create(AgentId('prefix-b'), { model: 'mock' })
|
const agentB = ctx.agentLoop.create(SessionId('prefix-b'), { model: 'mock' })
|
||||||
const seen: string[] = []
|
const seen: string[] = []
|
||||||
ctx.on('agent/session-prefix', async (agent, _prefix, _signal, next) => {
|
ctx.on('agent/session-prefix', async (agent, _prefix, _signal, next) => {
|
||||||
seen.push(`global:${agent.id}`)
|
seen.push(`global:${agent.id}`)
|
||||||
@@ -352,7 +348,7 @@ describe('agent/session-prefix', () => {
|
|||||||
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
|
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
|
||||||
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
|
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: '<system-reminder>catalog</system-reminder>' }] }
|
const reminder: Message = { role: 'user', content: [{ type: 'text', text: '<system-reminder>catalog</system-reminder>' }] }
|
||||||
let composed = 0
|
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 () => {
|
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 adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(adapter)
|
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 reminder: Message = { role: 'user', content: [{ type: 'text', text: 'opener' }] }
|
||||||
const order: string[] = []
|
const order: string[] = []
|
||||||
@@ -412,7 +408,7 @@ describe('agent/session-prefix', () => {
|
|||||||
it('the canonical prepend pattern composes contributions in registration order', async () => {
|
it('the canonical prepend pattern composes contributions in registration order', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(adapter)
|
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
|
// Both listeners use the canonical `[mine, ...await next()]` prepend: the
|
||||||
// waterfall unwinds innermost-first (the second listener's array is built
|
// 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 () => {
|
it('with no contributions the header omits messagePrefix and the request is the bare derivation', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(adapter)
|
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.
|
// A listener that delegates without contributing — the canonical no-op.
|
||||||
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => next())
|
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 () => {
|
it('the frozen seed rejects in-place mutation — a contribution is a returned extension', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(adapter)
|
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
|
let mutationError: unknown
|
||||||
ctx.on('agent/session-prefix', async (_agent, prefix, _signal, next): Promise<Message[]> => {
|
ctx.on('agent/session-prefix', async (_agent, prefix, _signal, next): Promise<Message[]> => {
|
||||||
@@ -479,7 +475,7 @@ describe('agent/session-prefix', () => {
|
|||||||
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
|
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
|
||||||
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
|
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' }] }
|
const held: Message = { role: 'user', content: [{ type: 'text', text: 'v1' }] }
|
||||||
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => [...await next(), held])
|
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => [...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 () => {
|
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 adapter = new MockAdapter([textResponse('step 1 no tools'), textResponse('step 2')])
|
||||||
const ctx = await harness(adapter)
|
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
|
let forced = false
|
||||||
ctx.on('agent/turn-continuation', async (_agent, _turn, _default, next): Promise<ContinuationDecision> => {
|
ctx.on('agent/turn-continuation', async (_agent, _turn, _default, next): Promise<ContinuationDecision> => {
|
||||||
@@ -533,7 +529,7 @@ describe('agent/turn-continuation (ContinuationDecision)', () => {
|
|||||||
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
|
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
|
||||||
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
|
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<ContinuationDecision> => ({ action: 'stop' }))
|
ctx.on('agent/turn-continuation', async (): Promise<ContinuationDecision> => ({ 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' } },
|
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
|
||||||
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
|
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.
|
// Each call attaches additionalContext naming itself.
|
||||||
ctx.on('tools/post-execute', async (exec, _result): Promise<PostToolDecision> =>
|
ctx.on('tools/post-execute', async (exec, _result): Promise<PostToolDecision> =>
|
||||||
@@ -599,7 +595,7 @@ describe('tools/pre-execute gate (native-plugin permission pattern, end-to-end t
|
|||||||
name: 'danger', description: 'danger', parameters: {},
|
name: 'danger', description: 'danger', parameters: {},
|
||||||
async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] },
|
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<PreToolDecision> => {
|
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
|
||||||
if (exec.name === 'danger') return { kind: 'deny', reason: 'blocked dangerous tool' }
|
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' } },
|
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
|
||||||
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
|
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')
|
send(agent, 'please echo hi')
|
||||||
await waitForIdle(ctx, agent)
|
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 adapter = new MockAdapter([textResponse('should not run')])
|
||||||
const ctx = await harness(adapter)
|
const ctx = await harness(adapter)
|
||||||
await ctx.plugin(NativeGuard)
|
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[] = []
|
const reasons: TurnEndReason[] = []
|
||||||
ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
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()
|
await fiber.dispose()
|
||||||
|
|
||||||
// After disposal, a destructive prompt is NOT blocked (the listener is gone).
|
// 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 /')
|
send(agent, 'run rm -rf /')
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
// the prompt ran (not rejected) — proving the prompt-submit listener was disposed
|
// the prompt ran (not rejected) — proving the prompt-submit listener was disposed
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
|
|||||||
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
|
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
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 AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||||
import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts'
|
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 () => {
|
it('runs a simple turn: queued message → model → idle, with ordered events', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('hello there')])
|
const adapter = new MockAdapter([textResponse('hello there')])
|
||||||
const ctx = await harness(adapter)
|
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
|
// All boundaries — turn and step — are durable session events on the
|
||||||
// session/event feed (no agent/* mirror). Record them in fire order to
|
// 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}` }]
|
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')
|
send(agent, 'use the tool')
|
||||||
await waitForIdle(ctx, agent)
|
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' }] } }
|
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')
|
send(agent, 'use the tool')
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
@@ -155,7 +156,7 @@ describe('agent loop', () => {
|
|||||||
return []
|
return []
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
|
||||||
|
|
||||||
send(agent, 'hi')
|
send(agent, 'hi')
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
@@ -169,7 +170,6 @@ describe('agent loop', () => {
|
|||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(adapter, 'Working in {{cwd}}.')
|
const ctx = await harness(adapter, 'Working in {{cwd}}.')
|
||||||
const handle = await ctx.agents.create({
|
const handle = await ctx.agents.create({
|
||||||
agentId: AgentId('a-cwd'),
|
|
||||||
sessionId: SessionId('s-cwd'),
|
sessionId: SessionId('s-cwd'),
|
||||||
meta: { cwd: '/work/space' },
|
meta: { cwd: '/work/space' },
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
@@ -192,7 +192,7 @@ describe('agent loop', () => {
|
|||||||
const ctx = await harness(adapter, 'In {{cwd}}.')
|
const ctx = await harness(adapter, 'In {{cwd}}.')
|
||||||
const errors: Error[] = []
|
const errors: Error[] = []
|
||||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(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')
|
send(agent, 'hi')
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
@@ -233,7 +233,7 @@ describe('agent loop', () => {
|
|||||||
ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => {
|
ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => {
|
||||||
return { ...config, model: 'mock' }
|
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')
|
send(agent, 'hi')
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
@@ -259,7 +259,7 @@ describe('agent loop', () => {
|
|||||||
parameters: {},
|
parameters: {},
|
||||||
execute: () => Promise.resolve({ content: [{ type: 'text' as const, text: 'apparent success' }], meta }),
|
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')
|
send(agent, 'use the tool')
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
@@ -288,7 +288,7 @@ describe('agent loop', () => {
|
|||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(adapter)
|
const ctx = await harness(adapter)
|
||||||
ctx.on('system-prompt/assemble', async () => ({ sections: [], tools: [], variables: {} }))
|
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')
|
send(agent, 'hi')
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
@@ -300,7 +300,7 @@ describe('agent loop', () => {
|
|||||||
it('records raw chunks for replay as assistant/chunk session events', async () => {
|
it('records raw chunks for replay as assistant/chunk session events', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('abc')])
|
const adapter = new MockAdapter([textResponse('abc')])
|
||||||
const ctx = await harness(adapter)
|
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')
|
send(agent, 'hi')
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
@@ -324,7 +324,7 @@ describe('agent loop', () => {
|
|||||||
])
|
])
|
||||||
const ctx = await harness(adapter)
|
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({
|
ctx.tools.register(defineTool({
|
||||||
name: 'slow',
|
name: 'slow',
|
||||||
description: '',
|
description: '',
|
||||||
@@ -356,7 +356,7 @@ describe('agent loop', () => {
|
|||||||
it('steering while idle behaves like send (starts a turn)', async () => {
|
it('steering while idle behaves like send (starts a turn)', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(adapter)
|
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' }])
|
agent.steer([{ type: 'text', text: 'hello' }])
|
||||||
await waitForIdle(ctx, agent)
|
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 () => {
|
it('inject() while idle wraps context in a one-shot turn, visible to the next request', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(adapter)
|
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' } })
|
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
|
// The idle inject records a self-contained turn (turn/start → context/message
|
||||||
@@ -393,7 +393,7 @@ describe('agent loop', () => {
|
|||||||
textResponse('done'),
|
textResponse('done'),
|
||||||
])
|
])
|
||||||
const ctx = await harness(adapter)
|
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
|
// 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
|
// inject must append the context/message into the ALREADY-open turn rather
|
||||||
// than wrap it in its own one-shot turn.
|
// than wrap it in its own one-shot turn.
|
||||||
@@ -427,7 +427,7 @@ describe('agent loop', () => {
|
|||||||
textResponse('step 3'),
|
textResponse('step 3'),
|
||||||
])
|
])
|
||||||
const ctx = await harness(adapter)
|
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
|
let steps = 0
|
||||||
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
|
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) }]
|
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)
|
ctx.on('agent/turn-continuation', async () => ({ action: 'stop' }) as const)
|
||||||
|
|
||||||
@@ -469,7 +469,7 @@ describe('agent loop', () => {
|
|||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(adapter)
|
const ctx = await harness(adapter)
|
||||||
ctx.llm.registerAdapter(['other-model'], 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) => {
|
ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => {
|
||||||
// The seed is frozen — config is not a mutable per-call knob; a switch
|
// 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: {},
|
name: 'echo', description: 'echo', parameters: {},
|
||||||
async execute() { return [{ type: 'text', text: 'echoed' }] },
|
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 }[] = []
|
const fires: { turn: number; step: number; fullSystemPrompt: string }[] = []
|
||||||
ctx.on('agent/pre-step', (subject, turn, step, fullSystemPrompt) => {
|
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).
|
// the derived request for that step (derive happens after step/start).
|
||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(adapter)
|
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
|
let injected = false
|
||||||
ctx.on('agent/pre-step', (subject) => {
|
ctx.on('agent/pre-step', (subject) => {
|
||||||
@@ -563,7 +563,7 @@ describe('agent loop', () => {
|
|||||||
// The loop survives and a follow-up prompt still runs.
|
// The loop survives and a follow-up prompt still runs.
|
||||||
const adapter = new MockAdapter([textResponse('second turn ok')])
|
const adapter = new MockAdapter([textResponse('second turn ok')])
|
||||||
const ctx = await harness(adapter)
|
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
|
let throwOnce = true
|
||||||
ctx.on('agent/pre-step', () => {
|
ctx.on('agent/pre-step', () => {
|
||||||
@@ -597,7 +597,7 @@ describe('agent loop', () => {
|
|||||||
it('cancel() mid-stream ends the turn with reason aborted', async () => {
|
it('cancel() mid-stream ends the turn with reason aborted', async () => {
|
||||||
const adapter = new MockAdapter(['hang'])
|
const adapter = new MockAdapter(['hang'])
|
||||||
const ctx = await harness(adapter)
|
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[] = []
|
const reasons: TurnEndReason[] = []
|
||||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
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.
|
// turn stops by default and ends max-tokens, not completed.
|
||||||
const adapter = new MockAdapter([maxTokensResponse('truncat')])
|
const adapter = new MockAdapter([maxTokensResponse('truncat')])
|
||||||
const ctx = await harness(adapter)
|
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[] = []
|
const reasons: TurnEndReason[] = []
|
||||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
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'),
|
textResponse('second half'),
|
||||||
])
|
])
|
||||||
const ctx = await harness(adapter)
|
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
|
let steps = 0
|
||||||
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
|
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.
|
// stop. The per-turn reason must be independent — turn 2 ends completed.
|
||||||
const adapter = new MockAdapter([maxTokensResponse('cut'), textResponse('clean')])
|
const adapter = new MockAdapter([maxTokensResponse('cut'), textResponse('clean')])
|
||||||
const ctx = await harness(adapter)
|
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[] = []
|
const reasons: TurnEndReason[] = []
|
||||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
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' }]
|
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[] = []
|
const reasons: TurnEndReason[] = []
|
||||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
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' } },
|
parameters: { text: { type: 'string' } },
|
||||||
async execute() { return [{ type: 'text', text: 'should not run' }] },
|
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[] = []
|
const reasons: TurnEndReason[] = []
|
||||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
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.
|
// on the normal step path suppresses a pure trace-only empty assistant/message.
|
||||||
const adapter = new MockAdapter([[{ type: 'finish', reason: { kind: 'stop' } }]])
|
const adapter = new MockAdapter([[{ type: 'finish', reason: { kind: 'stop' } }]])
|
||||||
const ctx = await harness(adapter)
|
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[] = []
|
const reasons: TurnEndReason[] = []
|
||||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
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' }])
|
expect(message.content).toEqual([{ type: 'text', text: 'partial text' }])
|
||||||
return next()
|
return next()
|
||||||
})
|
})
|
||||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
|
||||||
|
|
||||||
send(agent, 'go')
|
send(agent, 'go')
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
@@ -824,7 +824,7 @@ describe('agent loop', () => {
|
|||||||
return [{ type: 'text', text: String(args.text) }]
|
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
|
let threw = false
|
||||||
// Post-commit session observers cannot control the loop. The tool call still
|
// Post-commit session observers cannot control the loop. The tool call still
|
||||||
// drives the second model request, and the turn completes normally.
|
// 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 () => {
|
it('chains queued messages into consecutive turns', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
|
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
|
||||||
const ctx = await harness(adapter)
|
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[] = []
|
const turns: number[] = []
|
||||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) })
|
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 () => {
|
it('awaits session/flush at turn end (persistence checkpoint)', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(adapter)
|
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 flushed = 0
|
||||||
let flushedBeforeIdle = false
|
let flushedBeforeIdle = false
|
||||||
@@ -888,7 +888,7 @@ describe('agent loop', () => {
|
|||||||
it('errors from the model surface as agent/error and end the turn', async () => {
|
it('errors from the model surface as agent/error and end the turn', async () => {
|
||||||
const adapter = new MockAdapter([]) // script exhausted → throws
|
const adapter = new MockAdapter([]) // script exhausted → throws
|
||||||
const ctx = await harness(adapter)
|
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 errors: Error[] = []
|
||||||
const reasons: TurnEndReason[] = []
|
const reasons: TurnEndReason[] = []
|
||||||
@@ -913,10 +913,10 @@ describe('agent loop', () => {
|
|||||||
|
|
||||||
let agent!: ReactLoopAgent
|
let agent!: ReactLoopAgent
|
||||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
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'] }))
|
}, { inject: ['agentLoop'] }))
|
||||||
|
|
||||||
expect(ctx.agents.get(AgentId('scoped'))).toBe(agent)
|
expect(ctx.agents.get(SessionId('scoped'))).toBe(agent)
|
||||||
send(agent, 'go')
|
send(agent, 'go')
|
||||||
await new Promise(r => setTimeout(r, 30))
|
await new Promise(r => setTimeout(r, 30))
|
||||||
expect(agent.status).toBe('running')
|
expect(agent.status).toBe('running')
|
||||||
@@ -925,7 +925,7 @@ describe('agent loop', () => {
|
|||||||
await agent.done
|
await agent.done
|
||||||
|
|
||||||
expect(agent.status).toBe('disposed')
|
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')
|
expect(() => { send(agent, 'too late') }).toThrow('disposed')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -938,13 +938,14 @@ describe('agent loop', () => {
|
|||||||
await ctx.plugin(ToolRegistry)
|
await ctx.plugin(ToolRegistry)
|
||||||
await ctx.plugin(AgentRegistry)
|
await ctx.plugin(AgentRegistry)
|
||||||
await ctx.plugin(AgentLoop, {
|
await ctx.plugin(AgentLoop, {
|
||||||
agents: [{ id: AgentId('config-agent'), model: 'mock' }],
|
agents: [{ id: 'config-agent', model: 'mock' }],
|
||||||
})
|
})
|
||||||
ctx.llm.registerAdapter(['mock'], adapter)
|
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).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')
|
expect(agent.options.model).toBe('mock')
|
||||||
|
|
||||||
// the agent is alive: send triggers a turn
|
// the agent is alive: send triggers a turn
|
||||||
@@ -961,10 +962,10 @@ describe('agent loop', () => {
|
|||||||
await ctx.plugin(ToolRegistry)
|
await ctx.plugin(ToolRegistry)
|
||||||
await ctx.plugin(AgentRegistry)
|
await ctx.plugin(AgentRegistry)
|
||||||
await ctx.plugin(AgentLoop, {
|
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')
|
expect(agent.session.header.cwd).toBe('/work/project')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -982,7 +983,7 @@ describe('agent loop', () => {
|
|||||||
return [{ type: 'text', text: String(args.text) }]
|
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')
|
send(agent, 'run')
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ import { Context } from 'cordis'
|
|||||||
import LlmService from '@deepseek-ai/dsh-llm'
|
import LlmService from '@deepseek-ai/dsh-llm'
|
||||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||||
import { LlmAdapter } 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 SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
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 AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||||
import fc from 'fast-check'
|
import fc from 'fast-check'
|
||||||
|
|
||||||
@@ -95,7 +96,7 @@ describe('agent loop scheduling properties', () => {
|
|||||||
async (texts) => {
|
async (texts) => {
|
||||||
const ctx = await harness()
|
const ctx = await harness()
|
||||||
try {
|
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 { seen: trace } = recordStatus(ctx, agent)
|
||||||
const idle = nextIdle(ctx, agent)
|
const idle = nextIdle(ctx, agent)
|
||||||
// Send all in one synchronous tick: they queue before the loop wakes.
|
// Send all in one synchronous tick: they queue before the loop wakes.
|
||||||
@@ -120,7 +121,7 @@ describe('agent loop scheduling properties', () => {
|
|||||||
async (texts) => {
|
async (texts) => {
|
||||||
const ctx = await harness()
|
const ctx = await harness()
|
||||||
try {
|
try {
|
||||||
const agent = ctx.agentLoop.create(AgentId('a'), { model: 'mock' })
|
const agent = ctx.agentLoop.create(SessionId('a'), { model: 'mock' })
|
||||||
for (const text of texts) {
|
for (const text of texts) {
|
||||||
const idle = nextIdle(ctx, agent)
|
const idle = nextIdle(ctx, agent)
|
||||||
agent.send([{ type: 'text', text }])
|
agent.send([{ type: 'text', text }])
|
||||||
@@ -145,7 +146,7 @@ describe('agent loop scheduling properties', () => {
|
|||||||
async (steps) => {
|
async (steps) => {
|
||||||
const ctx = await harness()
|
const ctx = await harness()
|
||||||
try {
|
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
|
// Capture an idle waiter before EACH send; the last one is guaranteed
|
||||||
// to resolve because the final send always triggers (or joins) a turn
|
// 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
|
// that ends idle. Awaiting an already-resolved waiter is a no-op, so a
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import LlmService from '@deepseek-ai/dsh-llm'
|
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 SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
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 AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||||
|
|
||||||
@@ -71,7 +72,7 @@ function waitForIdle(context: Context, agent: Agent): Promise<void> {
|
|||||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('log-derived request cache hits (real API)', () => {
|
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 () => {
|
it('every request after the first hits the provider prefix cache', async () => {
|
||||||
ctx = await loopHarness()
|
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).
|
// 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.' }])
|
agent.send([{ type: 'text', text: 'Look up the key "deploy-color" with the lookup tool and tell me the value.' }])
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
|
|||||||
import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
|
import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
|
||||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
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 AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||||
|
|
||||||
@@ -74,7 +75,7 @@ describe('request stability across the loop', () => {
|
|||||||
])
|
])
|
||||||
const ctx = await harness(adapter)
|
const ctx = await harness(adapter)
|
||||||
registerEcho(ctx)
|
registerEcho(ctx)
|
||||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
|
||||||
|
|
||||||
send(agent, 'go')
|
send(agent, 'go')
|
||||||
await waitForIdle(ctx, agent)
|
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 () => {
|
it('a later turn append-extends the previous turn (one conversation, one log)', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||||
const ctx = await harness(adapter)
|
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')
|
send(agent, 'first')
|
||||||
await waitForIdle(ctx, agent)
|
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 () => {
|
it('a compaction replace rewrites the resend, and the log explains it', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||||
const ctx = await harness(adapter)
|
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')
|
send(agent, 'first')
|
||||||
await waitForIdle(ctx, agent)
|
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 () => {
|
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 adapter = new MockAdapter([textResponse('one'), textResponse('two'), textResponse('three')])
|
||||||
const ctx = await harness(adapter)
|
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')
|
send(agent, 'first')
|
||||||
await waitForIdle(ctx, agent)
|
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 () => {
|
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 adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||||
const ctx = await harness(adapter)
|
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
|
let injected = false
|
||||||
ctx.on('agent/request', async (_agent, _turn, _step, _config, next) => {
|
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 () => {
|
it('a mutation attempt on the frozen request content throws into the step (loud, not silent)', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('one')])
|
const adapter = new MockAdapter([textResponse('one')])
|
||||||
const ctx = await harness(adapter)
|
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 errors: Error[] = []
|
||||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(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 () => {
|
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 adapter = new MockAdapter([textResponse('one')])
|
||||||
const ctx = await harness(adapter)
|
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')
|
send(agent, 'first')
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
@@ -224,7 +225,6 @@ describe('request stability across the loop', () => {
|
|||||||
const adapter2 = new MockAdapter([textResponse('two')])
|
const adapter2 = new MockAdapter([textResponse('two')])
|
||||||
const ctx2 = await harness(adapter2)
|
const ctx2 = await harness(adapter2)
|
||||||
const handle = await ctx2.agents.create({
|
const handle = await ctx2.agents.create({
|
||||||
agentId: AgentId('gen2'),
|
|
||||||
sessionId: SessionId('gen2-session'),
|
sessionId: SessionId('gen2-session'),
|
||||||
seed: [...agent.session.events],
|
seed: [...agent.session.events],
|
||||||
agentOptions: { model: 'mock' },
|
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 () => {
|
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 adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||||
const ctx = await harness(adapter)
|
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) => {
|
ctx.on('agent/request', async (_agent, _turn, _step, _config, next) => {
|
||||||
const config = await next()
|
const config = await next()
|
||||||
@@ -278,7 +278,7 @@ describe('request stability across the loop', () => {
|
|||||||
])
|
])
|
||||||
const ctx = await harness(adapter)
|
const ctx = await harness(adapter)
|
||||||
registerEcho(ctx)
|
registerEcho(ctx)
|
||||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
|
||||||
|
|
||||||
send(agent, 'go')
|
send(agent, 'go')
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
|||||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
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 SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
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))
|
ctx.on('session/created', () => throwUnknown(failure))
|
||||||
|
|
||||||
await expect(ctx.agents.resume({
|
await expect(ctx.agents.resume({
|
||||||
agentId: AgentId('unknown-resume-failure'),
|
|
||||||
resumeSessionId: sessionId,
|
resumeSessionId: sessionId,
|
||||||
})).rejects.toBe(failure)
|
})).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()
|
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||||
await ctx.fiber.dispose()
|
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 () => {
|
it('createAgent uses the caller-supplied sessionId (not ${id}-session)', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('hi')])
|
const adapter = new MockAdapter([textResponse('hi')])
|
||||||
const { ctx } = await persistentHarness(adapter)
|
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.id).toBe('custom-session')
|
||||||
expect(agent.session.header.cwd).toBe('/w')
|
expect(agent.session.header.cwd).toBe('/w')
|
||||||
await ctx.fiber.dispose()
|
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 adapter = new MockAdapter([textResponse('hi')])
|
||||||
const { ctx } = await persistentHarness(adapter)
|
const { ctx } = await persistentHarness(adapter)
|
||||||
await ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-a') })
|
const sessionId = SessionId('sess-a')
|
||||||
// A second create with the SAME agent id but a fresh session id must reject
|
await ctx.agents.create({ sessionId })
|
||||||
// up front — and must NOT leave an orphaned 'sess-b' session behind.
|
await expect(ctx.agents.create({ sessionId })).rejects.toThrow(/already exists/)
|
||||||
await expect(ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-b') })).rejects.toThrow(/already registered/)
|
expect(ctx.sessions.list()).toHaveLength(1)
|
||||||
expect(ctx.sessions.get(SessionId('sess-b'))).toBeUndefined()
|
|
||||||
await ctx.fiber.dispose()
|
await ctx.fiber.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('createAgent works without meta (no cwd)', async () => {
|
it('createAgent works without meta (no cwd)', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('hi')])
|
const adapter = new MockAdapter([textResponse('hi')])
|
||||||
const { ctx } = await persistentHarness(adapter)
|
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.id).toBe('nometa-session')
|
||||||
expect(agent.session.header.cwd).toBeUndefined()
|
expect(agent.session.header.cwd).toBeUndefined()
|
||||||
await ctx.fiber.dispose()
|
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.
|
// Lifecycle 1: create a no-cwd session and run a turn.
|
||||||
const adapter1 = new MockAdapter([textResponse('a')])
|
const adapter1 = new MockAdapter([textResponse('a')])
|
||||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
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' } })
|
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||||
await waitForIdle(ctx1, a1)
|
await waitForIdle(ctx1, a1)
|
||||||
await ctx1.fiber.dispose()
|
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(AgentLoop, { agents: [] })
|
||||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||||
ctx2.llm.registerAdapter(['mock'], adapter2)
|
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()
|
expect(a2.session.header.cwd).toBeUndefined()
|
||||||
await ctx2.fiber.dispose()
|
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 { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||||
const sources1: string[] = []
|
const sources1: string[] = []
|
||||||
ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source))
|
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'])
|
expect(sources1).toEqual(['startup'])
|
||||||
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||||
await waitForIdle(ctx1, a1)
|
await waitForIdle(ctx1, a1)
|
||||||
@@ -171,7 +170,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
|||||||
ctx2.llm.registerAdapter(['mock'], adapter2)
|
ctx2.llm.registerAdapter(['mock'], adapter2)
|
||||||
const sources2: string[] = []
|
const sources2: string[] = []
|
||||||
ctx2.on('agent/session-start', (_agent, source) => void sources2.push(source))
|
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'])
|
expect(sources2).toEqual(['resume'])
|
||||||
await ctx2.fiber.dispose()
|
await ctx2.fiber.dispose()
|
||||||
})
|
})
|
||||||
@@ -186,7 +185,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
|||||||
|
|
||||||
ctx.on('session/created', (session) => {
|
ctx.on('session/created', (session) => {
|
||||||
expect(ctx.sessions.get(session.id)).toBe(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')
|
order.push('session/created')
|
||||||
})
|
})
|
||||||
ctx.on('agent/created', (agent) => {
|
ctx.on('agent/created', (agent) => {
|
||||||
@@ -199,11 +198,10 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const resuming = ctx.agents.resume({
|
const resuming = ctx.agents.resume({
|
||||||
agentId: AgentId('resumed-atomic'),
|
|
||||||
resumeSessionId: sessionId,
|
resumeSessionId: sessionId,
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
setup: async (agentCtx) => {
|
setup: async (agentCtx) => {
|
||||||
expect(agentCtx.agent?.id).toBe(AgentId('resumed-atomic'))
|
expect(agentCtx.agent?.id).toBe(sessionId)
|
||||||
expect(agentCtx.agent?.session.events).toHaveLength(2)
|
expect(agentCtx.agent?.session.events).toHaveLength(2)
|
||||||
agentCtx.on('session/created', () => void order.push('setup-listener:session/created'))
|
agentCtx.on('session/created', () => void order.push('setup-listener:session/created'))
|
||||||
agentCtx.on('agent/created', () => void order.push('setup-listener:agent/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
|
await setupStarted.promise
|
||||||
expect(ctx.agents.get(AgentId('resumed-atomic'))).toBeUndefined()
|
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||||
expect(order).toEqual(['setup:start'])
|
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 () => {
|
it('successful resume disposal retires its caller-owned transaction effects', async () => {
|
||||||
const sessionId = SessionId('resume-retired-effects-s')
|
const sessionId = SessionId('resume-retired-effects-s')
|
||||||
const agentId = AgentId('resume-retired-effects')
|
|
||||||
const root = await persistSession(sessionId)
|
const root = await persistSession(sessionId)
|
||||||
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
||||||
const handle = await ctx.agents.resume({
|
const handle = await ctx.agents.resume({
|
||||||
agentId,
|
|
||||||
resumeSessionId: sessionId,
|
resumeSessionId: sessionId,
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
})
|
})
|
||||||
const transactionLabels = [
|
const transactionLabels = [
|
||||||
`agentLoop.owner(${agentId})`,
|
`agentLoop.owner(${sessionId})`,
|
||||||
`agentLoop.lifecycle(${agentId})`,
|
`agentLoop.lifecycle(${sessionId})`,
|
||||||
]
|
]
|
||||||
|
|
||||||
expect(ctx.fiber.getEffects().map(effect => effect.label)).toEqual(expect.arrayContaining(transactionLabels))
|
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()
|
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 sessionId = SessionId('resume-setup-reject')
|
||||||
const root = await persistSession(sessionId)
|
const root = await persistSession(sessionId)
|
||||||
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
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'))
|
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
|
||||||
|
|
||||||
await expect(ctx.agents.resume({
|
await expect(ctx.agents.resume({
|
||||||
agentId: AgentId('resume-reject'),
|
|
||||||
resumeSessionId: sessionId,
|
resumeSessionId: sessionId,
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
setup: async () => {
|
setup: async () => {
|
||||||
@@ -275,10 +270,9 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
|||||||
})).rejects.toThrow('resume setup failed')
|
})).rejects.toThrow('resume setup failed')
|
||||||
|
|
||||||
expect(published).toEqual([])
|
expect(published).toEqual([])
|
||||||
expect(ctx.agents.get(AgentId('resume-reject'))).toBeUndefined()
|
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||||
const retry = await ctx.agents.resume({
|
const retry = await ctx.agents.resume({
|
||||||
agentId: AgentId('resume-reject'),
|
|
||||||
resumeSessionId: sessionId,
|
resumeSessionId: sessionId,
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
})
|
})
|
||||||
@@ -299,7 +293,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
|||||||
let resuming!: ReturnType<typeof ctx.agents.resume>
|
let resuming!: ReturnType<typeof ctx.agents.resume>
|
||||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||||
resuming = inner.agents.resume({
|
resuming = inner.agents.resume({
|
||||||
agentId: AgentId('resume-owner-race'),
|
|
||||||
resumeSessionId: sessionId,
|
resumeSessionId: sessionId,
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
setup: async () => {
|
setup: async () => {
|
||||||
@@ -313,7 +306,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
|||||||
await owner.dispose()
|
await owner.dispose()
|
||||||
await expect(resuming).rejects.toThrow(/owner disposed during setup/)
|
await expect(resuming).rejects.toThrow(/owner disposed during setup/)
|
||||||
expect(published).toEqual([])
|
expect(published).toEqual([])
|
||||||
expect(ctx.agents.get(AgentId('resume-owner-race'))).toBeUndefined()
|
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||||
|
|
||||||
gate.resolve(undefined)
|
gate.resolve(undefined)
|
||||||
@@ -322,9 +315,8 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
|||||||
await ctx.fiber.dispose()
|
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 sessionId = SessionId('resume-load-owner-unload')
|
||||||
const agentId = AgentId('resume-load-race')
|
|
||||||
const root = await persistSession(sessionId)
|
const root = await persistSession(sessionId)
|
||||||
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
||||||
const snapshot = await ctx.sessionPersistence.load(sessionId)
|
const snapshot = await ctx.sessionPersistence.load(sessionId)
|
||||||
@@ -348,19 +340,19 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
|||||||
|
|
||||||
let resuming!: ReturnType<typeof ctx.agents.resume>
|
let resuming!: ReturnType<typeof ctx.agents.resume>
|
||||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
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'] }))
|
}, { inject: ['agents'] }))
|
||||||
await loadStarted.promise
|
await loadStarted.promise
|
||||||
|
|
||||||
const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during setup/)
|
const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during setup/)
|
||||||
await promptly(owner.dispose())
|
await promptly(owner.dispose())
|
||||||
expect(published).toEqual([])
|
expect(published).toEqual([])
|
||||||
expect(ctx.agents.get(agentId)).toBeUndefined()
|
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||||
|
|
||||||
// owner.dispose() awaited transaction settlement, so the same identities
|
// owner.dispose() awaited transaction settlement, so the identity can be
|
||||||
// can be reused before awaiting the public rejection.
|
// reused before awaiting the public rejection.
|
||||||
const retry = await promptly(ctx.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } }))
|
const retry = await promptly(ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: { model: 'mock' } }))
|
||||||
await rejection
|
await rejection
|
||||||
expect(loads).toBe(2)
|
expect(loads).toBe(2)
|
||||||
expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start'])
|
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))
|
lateLoad.resolve(structuredClone(snapshot))
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
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(ctx.sessions.get(sessionId)).toBe(retry.agent.session)
|
||||||
expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start'])
|
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 () => {
|
it('AgentLoop unload aborts persistence load and awaits wrapper settlement', async () => {
|
||||||
const sessionId = SessionId('resume-load-factory-unload')
|
const sessionId = SessionId('resume-load-factory-unload')
|
||||||
const agentId = AgentId('resume-load-factory-race')
|
|
||||||
const root = await persistSession(sessionId)
|
const root = await persistSession(sessionId)
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(LlmService)
|
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('session/created', () => void published.push('session/created'))
|
||||||
ctx.on('agent/created', () => void published.push('agent/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
|
await loadStarted.promise
|
||||||
const rejection = expect(promptly(resuming)).rejects.toThrow(/agent loop is not active/)
|
const rejection = expect(promptly(resuming)).rejects.toThrow(/agent loop is not active/)
|
||||||
await promptly(loopFiber.dispose())
|
await promptly(loopFiber.dispose())
|
||||||
await rejection
|
await rejection
|
||||||
|
|
||||||
expect(published).toEqual([])
|
expect(published).toEqual([])
|
||||||
expect(ctx.agents.get(agentId)).toBeUndefined()
|
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||||
lateLoad.resolve(structuredClone(snapshot))
|
lateLoad.resolve(structuredClone(snapshot))
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
@@ -452,7 +443,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
|||||||
await ctx2.plugin(AgentLoop, { agents: [] })
|
await ctx2.plugin(AgentLoop, { agents: [] })
|
||||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||||
ctx2.llm.registerAdapter(['mock'], adapter2)
|
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.parentSession).toBe('parent-sess')
|
||||||
expect(a2.session.header.cwd).toBe('/w')
|
expect(a2.session.header.cwd).toBe('/w')
|
||||||
expect(a2.session.header.seedLength).toBe(seed.length)
|
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.
|
// disk, since a crash before the next turn would otherwise lose it.
|
||||||
const adapter1 = new MockAdapter([textResponse('answer')])
|
const adapter1 = new MockAdapter([textResponse('answer')])
|
||||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
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' } })
|
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||||
await waitForIdle(ctx1, a1)
|
await waitForIdle(ctx1, a1)
|
||||||
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
|
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).
|
// drop it on reload (the bug this guards).
|
||||||
const adapter1 = new MockAdapter([textResponse('answer')])
|
const adapter1 = new MockAdapter([textResponse('answer')])
|
||||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
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' } })
|
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||||
await waitForIdle(ctx1, a1)
|
await waitForIdle(ctx1, a1)
|
||||||
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
|
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(AgentLoop, { agents: [] })
|
||||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||||
ctx2.llm.registerAdapter(['mock'], adapter2)
|
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())
|
const flat = JSON.stringify(a2.session.deriveMessages())
|
||||||
expect(flat).toContain('background task 42 finished')
|
expect(flat).toContain('background task 42 finished')
|
||||||
await ctx2.fiber.dispose()
|
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.
|
// Lifecycle 1: run one full turn, persisting it.
|
||||||
const adapter1 = new MockAdapter([textResponse('first answer')])
|
const adapter1 = new MockAdapter([textResponse('first answer')])
|
||||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
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' } })
|
a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } })
|
||||||
await waitForIdle(ctx1, a1)
|
await waitForIdle(ctx1, a1)
|
||||||
const events1 = [...a1.session.events]
|
const events1 = [...a1.session.events]
|
||||||
@@ -539,7 +530,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
|||||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||||
ctx2.llm.registerAdapter(['mock'], adapter2)
|
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…
|
// The resumed session carries the prior history…
|
||||||
expect(a2.session.id).toBe('sess-resume')
|
expect(a2.session.id).toBe('sess-resume')
|
||||||
expect(a2.session.events.length).toBe(events1.length)
|
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(AgentRegistry)
|
||||||
await ctx.plugin(AgentLoop, { agents: [] })
|
await ctx.plugin(AgentLoop, { agents: [] })
|
||||||
ctx.llm.registerAdapter(['mock'], adapter)
|
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/)
|
.rejects.toThrow(/session persistence is not configured/)
|
||||||
await ctx.fiber.dispose()
|
await ctx.fiber.dispose()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import LlmService, { CallId, ContentBlock, MessageSource, StreamChunk } from '@d
|
|||||||
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
|
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
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 AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||||
import { prepareReactLoopAgent } from '../src/agent.ts'
|
import { prepareReactLoopAgent } from '../src/agent.ts'
|
||||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
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' }]
|
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.
|
// Plugin rewrites the message: replaces the text AND adds a tool call.
|
||||||
let rewritten = false
|
let rewritten = false
|
||||||
@@ -104,7 +105,7 @@ describe('HIGH: abort during tool execution ends the turn', () => {
|
|||||||
])
|
])
|
||||||
const ctx = await harness(adapter)
|
const ctx = await harness(adapter)
|
||||||
const executed: string[] = []
|
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({
|
ctx.tools.register(defineTool({
|
||||||
name: 'aborter',
|
name: 'aborter',
|
||||||
description: '',
|
description: '',
|
||||||
@@ -148,7 +149,7 @@ describe('HIGH: steering from late extension points is never stranded', () => {
|
|||||||
textResponse('continued because of steering'),
|
textResponse('continued because of steering'),
|
||||||
])
|
])
|
||||||
const ctx = await harness(adapter)
|
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
|
let steeredOnce = false
|
||||||
ctx.on('agent/turn-continuation', async (_agent, _turn, _decision, next) => {
|
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'),
|
textResponse('after goal reminder'),
|
||||||
])
|
])
|
||||||
const ctx = await harness(adapter)
|
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
|
let steeredOnce = false
|
||||||
ctx.on('session/event', (subject, event) => {
|
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 () => {
|
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 adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
|
||||||
const ctx = await harness(adapter)
|
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[] = []
|
const turns: number[] = []
|
||||||
let steeredOnce = false
|
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 () => {
|
it('steering queued during an aborted step is re-delivered, not silently consumed', async () => {
|
||||||
const adapter = new MockAdapter(['hang', textResponse('recovered')])
|
const adapter = new MockAdapter(['hang', textResponse('recovered')])
|
||||||
const ctx = await harness(adapter)
|
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')
|
send(agent, 'go')
|
||||||
await new Promise(r => setTimeout(r, 30))
|
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 () => {
|
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 adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||||
const ctx = await harness(adapter)
|
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
|
let threwOnce = false
|
||||||
ctx.on('agent/turn-continuation', async (): Promise<ContinuationDecision> => {
|
ctx.on('agent/turn-continuation', async (): Promise<ContinuationDecision> => {
|
||||||
@@ -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 () => {
|
it('a rejecting session/flush listener is reported but does not kill the agent', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||||
const ctx = await harness(adapter)
|
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
|
let rejectedOnce = false
|
||||||
ctx.on('session/flush', async () => {
|
ctx.on('session/flush', async () => {
|
||||||
@@ -325,7 +326,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => {
|
|||||||
|
|
||||||
let agent!: ReactLoopAgent
|
let agent!: ReactLoopAgent
|
||||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
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'] }))
|
}, { inject: ['agentLoop'] }))
|
||||||
|
|
||||||
const statuses: string[] = []
|
const statuses: string[] = []
|
||||||
@@ -348,7 +349,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => {
|
|||||||
|
|
||||||
let agent!: ReactLoopAgent
|
let agent!: ReactLoopAgent
|
||||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
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'] }))
|
}, { inject: ['agentLoop'] }))
|
||||||
|
|
||||||
ctx.on('agent/status', (_agent, status) => {
|
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
|
await agent.done // must not hang
|
||||||
|
|
||||||
expect(agent.status).toBe('disposed')
|
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 () => {
|
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 adapter = new MockAdapter([textResponse('never')])
|
||||||
const ctx = await harness(adapter)
|
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[] = []
|
const errors: Error[] = []
|
||||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(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 () => {
|
it('the agent/request waterfall can supply the model for a model-less agent', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('routed')])
|
const adapter = new MockAdapter([textResponse('routed')])
|
||||||
const ctx = await harness(adapter)
|
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) => {
|
ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => {
|
||||||
return { ...config, model: 'mock' }
|
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 () => {
|
it('agent/queued carries the resolved source; steering/message records its source', async () => {
|
||||||
const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')])
|
const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')])
|
||||||
const ctx = await harness(adapter)
|
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({
|
ctx.tools.register(defineTool({
|
||||||
name: 'noop',
|
name: 'noop',
|
||||||
description: '',
|
description: '',
|
||||||
@@ -438,7 +439,7 @@ describe('MEDIUM: misc registry and config fixes', () => {
|
|||||||
it('send() owns content and source before notification and delivery', async () => {
|
it('send() owns content and source before notification and delivery', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('done')])
|
const adapter = new MockAdapter([textResponse('done')])
|
||||||
const ctx = await harness(adapter)
|
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 content = [{ type: 'text' as const, text: 'accepted-send' }]
|
||||||
const source = { kind: 'plugin' as const, plugin: 'accepted-source' }
|
const source = { kind: 'plugin' as const, plugin: 'accepted-source' }
|
||||||
let notifiedContent: ContentBlock[] | undefined
|
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 () => {
|
it('running steer() owns content and source before notification and delivery', async () => {
|
||||||
const adapter = new MockAdapter([toolCallResponse('c1', 'gate', {}), textResponse('done')])
|
const adapter = new MockAdapter([toolCallResponse('c1', 'gate', {}), textResponse('done')])
|
||||||
const ctx = await harness(adapter)
|
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<undefined>()
|
const entered = Promise.withResolvers<undefined>()
|
||||||
const release = Promise.withResolvers<undefined>()
|
const release = Promise.withResolvers<undefined>()
|
||||||
ctx.tools.register(defineTool({
|
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 () => {
|
it('a forked agent continues turn numbers after the seed log', async () => {
|
||||||
const first = new MockAdapter([textResponse('turn one')])
|
const first = new MockAdapter([textResponse('turn one')])
|
||||||
const ctx = await harness(first)
|
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')
|
send(agent, 'first')
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
@@ -544,7 +545,7 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', ()
|
|||||||
ctx2.llm.registerAdapter(['mock'], second)
|
ctx2.llm.registerAdapter(['mock'], second)
|
||||||
|
|
||||||
const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] })
|
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
|
const forked = prepared.agent
|
||||||
prepared.markPublished()
|
prepared.markPublished()
|
||||||
ctx2.effect(() => prepared.startDriver())
|
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 adapter = new MockAdapter([errorStream])
|
||||||
const ctx = await harness(adapter)
|
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[] = []
|
const reasons: TurnEndReason[] = []
|
||||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
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 adapter = new MockAdapter([abortedStream])
|
||||||
const ctx = await harness(adapter)
|
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[] = []
|
const reasons: TurnEndReason[] = []
|
||||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
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 adapter = new MockAdapter([errorStream])
|
||||||
const ctx = await harness(adapter)
|
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[] = []
|
const reasons: TurnEndReason[] = []
|
||||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
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 () => {
|
it('the step/start event is in session.events when its session/event listener fires', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('done')])
|
const adapter = new MockAdapter([textResponse('done')])
|
||||||
const ctx = await harness(adapter)
|
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,
|
// Session.append pushes the event BEFORE notifying session/event listeners,
|
||||||
// so a step/start listener always finds the matching event already in the
|
// 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 () => {
|
it('a throwing step/start observer cannot change a successful turn', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('request completed')])
|
const adapter = new MockAdapter([textResponse('request completed')])
|
||||||
const ctx = await balancedHarness(adapter)
|
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,
|
// Session owns post-commit containment. The loop sees a successful append,
|
||||||
// runs the request, and balances the ordinary step and turn boundaries.
|
// 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 () => {
|
it('a pre-commit step/start validation failure does not invent a step boundary', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('never reached')])
|
const adapter = new MockAdapter([textResponse('never reached')])
|
||||||
const ctx = await balancedHarness(adapter)
|
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
|
let rejected = false
|
||||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||||
if (name !== 'session/event') return
|
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 errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider failed' } }]
|
||||||
const adapter = new MockAdapter([errorStream])
|
const adapter = new MockAdapter([errorStream])
|
||||||
const ctx = await balancedHarness(adapter)
|
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
|
let rejected = false
|
||||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||||
if (name !== 'session/event') return
|
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 () => {
|
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 adapter = new MockAdapter([textResponse('completed before close validation')])
|
||||||
const ctx = await balancedHarness(adapter)
|
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
|
let rejected = false
|
||||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||||
if (name !== 'session/event') return
|
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 errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }]
|
||||||
const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
|
const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
|
||||||
const ctx = await balancedHarness(adapter)
|
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
|
let threw = false
|
||||||
ctx.on('agent/error', () => { if (!threw) { threw = true; throw new Error('boom error-listener') } })
|
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)
|
const ctx = await balancedHarness(adapter)
|
||||||
let agent!: ReactLoopAgent
|
let agent!: ReactLoopAgent
|
||||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
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'] }))
|
}, { inject: ['agentLoop'] }))
|
||||||
|
|
||||||
const reasons: TurnEndReason[] = []
|
const reasons: TurnEndReason[] = []
|
||||||
@@ -901,7 +902,7 @@ describe('turn and step boundary recovery', () => {
|
|||||||
const ctx = await balancedHarness(adapter)
|
const ctx = await balancedHarness(adapter)
|
||||||
let agent!: ReactLoopAgent
|
let agent!: ReactLoopAgent
|
||||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
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'] }))
|
}, { inject: ['agentLoop'] }))
|
||||||
|
|
||||||
let threw = false
|
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 () => {
|
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 adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
|
||||||
const ctx = await harness(adapter)
|
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
|
let threw = false
|
||||||
ctx.on('session/event', (_session, event) => {
|
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 () => {
|
it('a throwing step/end observer cannot rewrite the turn outcome', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('all good'), textResponse('turn 2 ok')])
|
const adapter = new MockAdapter([textResponse('all good'), textResponse('turn 2 ok')])
|
||||||
const ctx = await balancedHarness(adapter)
|
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
|
let threw = false
|
||||||
ctx.on('session/event', (_s, event) => {
|
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 errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }]
|
||||||
const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
|
const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
|
||||||
const ctx = await harness(adapter)
|
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
|
let threw = false
|
||||||
ctx.on('session/event', (_s, event) => {
|
ctx.on('session/event', (_s, event) => {
|
||||||
@@ -1038,7 +1039,7 @@ describe('turn and step boundary recovery', () => {
|
|||||||
// boundary stays authoritative and the loop continues normally.
|
// boundary stays authoritative and the loop continues normally.
|
||||||
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
|
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
|
||||||
const ctx = await harness(adapter)
|
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
|
let threw = false
|
||||||
ctx.on('session/event', (_s, event) => {
|
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' }] })
|
return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'ok' }] })
|
||||||
}, { prepend: true })
|
}, { 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')
|
send(agent, 'use tool')
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
@@ -1120,7 +1121,7 @@ describe('surface: assistant/message omits sourceEventSeqs when no chunks stream
|
|||||||
const adapter = new MockAdapter([[]])
|
const adapter = new MockAdapter([[]])
|
||||||
const ctx = await harness(adapter)
|
const ctx = await harness(adapter)
|
||||||
await ctx.plugin(Invariants)
|
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) => ({
|
ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _next) => ({
|
||||||
role: 'assistant' as const,
|
role: 'assistant' as const,
|
||||||
@@ -1171,7 +1172,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
|||||||
|
|
||||||
let agent!: ReactLoopAgent
|
let agent!: ReactLoopAgent
|
||||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
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'] }))
|
}, { inject: ['agentLoop'] }))
|
||||||
|
|
||||||
const reasons: TurnEndReason[] = []
|
const reasons: TurnEndReason[] = []
|
||||||
@@ -1227,7 +1228,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
|||||||
|
|
||||||
let agent!: ReactLoopAgent
|
let agent!: ReactLoopAgent
|
||||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
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'] }))
|
}, { inject: ['agentLoop'] }))
|
||||||
|
|
||||||
const reasons: TurnEndReason[] = []
|
const reasons: TurnEndReason[] = []
|
||||||
@@ -1282,7 +1283,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
|||||||
|
|
||||||
let agent!: ReactLoopAgent
|
let agent!: ReactLoopAgent
|
||||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
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'] }))
|
}, { inject: ['agentLoop'] }))
|
||||||
|
|
||||||
const reasons: TurnEndReason[] = []
|
const reasons: TurnEndReason[] = []
|
||||||
@@ -1334,7 +1335,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
|||||||
|
|
||||||
let agent!: ReactLoopAgent
|
let agent!: ReactLoopAgent
|
||||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
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'] }))
|
}, { inject: ['agentLoop'] }))
|
||||||
|
|
||||||
const reasons: TurnEndReason[] = []
|
const reasons: TurnEndReason[] = []
|
||||||
@@ -1384,7 +1385,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
|||||||
|
|
||||||
let agent!: ReactLoopAgent
|
let agent!: ReactLoopAgent
|
||||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
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'] }))
|
}, { inject: ['agentLoop'] }))
|
||||||
|
|
||||||
send(agent, 'go')
|
send(agent, 'go')
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import LlmService from '@deepseek-ai/dsh-llm'
|
|||||||
import SessionStore, { SessionId, 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 SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
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 type { Agent } from '@deepseek-ai/dsh-agent'
|
||||||
import { scopeOf } from '@deepseek-ai/dsh-scope'
|
import { scopeOf } from '@deepseek-ai/dsh-scope'
|
||||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||||
@@ -57,33 +58,31 @@ function disposeCurrentLifecycle(ownerCtx: Context): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('agent scope lifecycle', () => {
|
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 ctx = await harness()
|
||||||
const reason = new Error('cancelled before creation')
|
const reason = new Error('cancelled before creation')
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
controller.abort(reason)
|
controller.abort(reason)
|
||||||
|
|
||||||
await expect(ctx.agents.create({
|
await expect(ctx.agents.create({
|
||||||
agentId: AgentId('pre-aborted'),
|
|
||||||
sessionId: SessionId('pre-aborted-s'),
|
sessionId: SessionId('pre-aborted-s'),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})).rejects.toBe(reason)
|
})).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()
|
expect(ctx.sessions.get(SessionId('pre-aborted-s'))).toBeUndefined()
|
||||||
|
|
||||||
const valueController = new AbortController()
|
const valueController = new AbortController()
|
||||||
valueController.abort('plain cancellation reason')
|
valueController.abort('plain cancellation reason')
|
||||||
await expect(ctx.agents.create({
|
await expect(ctx.agents.create({
|
||||||
agentId: AgentId('pre-aborted-value'),
|
|
||||||
sessionId: SessionId('pre-aborted-value-s'),
|
sessionId: SessionId('pre-aborted-value-s'),
|
||||||
signal: valueController.signal,
|
signal: valueController.signal,
|
||||||
})).rejects.toMatchObject({
|
})).rejects.toMatchObject({
|
||||||
message: 'agent "pre-aborted-value" creation aborted',
|
message: 'agent "pre-aborted-value-s" creation aborted',
|
||||||
cause: 'plain cancellation reason',
|
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()
|
expect(ctx.sessions.get(SessionId('pre-aborted-value-s'))).toBeUndefined()
|
||||||
await ctx.fiber.dispose()
|
await ctx.fiber.dispose()
|
||||||
})
|
})
|
||||||
@@ -100,12 +99,11 @@ describe('agent scope lifecycle', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await expect(ctx.agents.create({
|
await expect(ctx.agents.create({
|
||||||
agentId: AgentId('prepare-abort'),
|
|
||||||
sessionId: SessionId('prepare-abort-s'),
|
sessionId: SessionId('prepare-abort-s'),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})).rejects.toBe(reason)
|
})).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()
|
expect(ctx.sessions.get(SessionId('prepare-abort-s'))).toBeUndefined()
|
||||||
await ctx.fiber.dispose()
|
await ctx.fiber.dispose()
|
||||||
})
|
})
|
||||||
@@ -124,7 +122,7 @@ describe('agent scope lifecycle', () => {
|
|||||||
thrown = createFailure
|
thrown = createFailure
|
||||||
let createCaught: unknown
|
let createCaught: unknown
|
||||||
try {
|
try {
|
||||||
ctx.agentLoop.create(AgentId('unknown-create'))
|
ctx.agentLoop.create(SessionId('unknown-create'))
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
createCaught = error
|
createCaught = error
|
||||||
}
|
}
|
||||||
@@ -133,28 +131,27 @@ describe('agent scope lifecycle', () => {
|
|||||||
const ownedFailure = { source: 'createAgent' }
|
const ownedFailure = { source: 'createAgent' }
|
||||||
thrown = ownedFailure
|
thrown = ownedFailure
|
||||||
await expect(ctx.agents.create({
|
await expect(ctx.agents.create({
|
||||||
agentId: AgentId('unknown-owned-create'),
|
|
||||||
sessionId: SessionId('unknown-owned-create-s'),
|
sessionId: SessionId('unknown-owned-create-s'),
|
||||||
})).rejects.toBe(ownedFailure)
|
})).rejects.toBe(ownedFailure)
|
||||||
|
|
||||||
expect(ctx.agents.get(AgentId('unknown-create'))).toBeUndefined()
|
expect(ctx.agents.get(SessionId('unknown-create'))).toBeUndefined()
|
||||||
expect(ctx.agents.get(AgentId('unknown-owned-create'))).toBeUndefined()
|
expect(ctx.agents.get(SessionId('unknown-owned-create-s'))).toBeUndefined()
|
||||||
await ctx.fiber.dispose()
|
await ctx.fiber.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('wires agent.ctx: tagged with the agent, DX field set, ctx.agent safe elsewhere', async () => {
|
it('wires agent.ctx: tagged with the agent, DX field set, ctx.agent safe elsewhere', async () => {
|
||||||
const ctx = await harness()
|
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(scopeOf(agent.ctx)).toBe(agent)
|
||||||
expect(agent.ctx.agent).toBe(agent)
|
expect(agent.ctx.agent).toBe(agent)
|
||||||
// The root accessor default: a plain context answers undefined, not a throw.
|
// The root accessor default: a plain context answers undefined, not a throw.
|
||||||
expect(ctx.agent).toBeUndefined()
|
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 () => {
|
it('scoped registrations live in the agent world and die with the agent', async () => {
|
||||||
const ctx = await harness()
|
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
|
const { agent } = handle
|
||||||
agent.ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You run tests.' })
|
agent.ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You run tests.' })
|
||||||
agent.ctx.tools.register({
|
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 () => {
|
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 ctx = await harness(new MockAdapter([textResponse('one'), textResponse('two')]))
|
||||||
const a = ctx.agentLoop.create(AgentId('a'), { model: 'mock' })
|
const a = ctx.agentLoop.create(SessionId('a'), { model: 'mock' })
|
||||||
const b = ctx.agentLoop.create(AgentId('b'), { model: 'mock' })
|
const b = ctx.agentLoop.create(SessionId('b'), { model: 'mock' })
|
||||||
|
|
||||||
const heard: string[] = []
|
const heard: string[] = []
|
||||||
a.ctx.on('agent/status', (subject, status) => void heard.push(`a-sees:${subject.id}:${status}`))
|
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({
|
const handle = await ctx.agents.create({
|
||||||
agentId: AgentId('child'),
|
|
||||||
sessionId: SessionId('child-s'),
|
sessionId: SessionId('child-s'),
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
setup: async (agentCtx) => {
|
setup: async (agentCtx) => {
|
||||||
@@ -224,14 +220,14 @@ describe('agent scope lifecycle', () => {
|
|||||||
await handle.dispose()
|
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 ctx = await harness()
|
||||||
const gate = Promise.withResolvers<undefined>()
|
const gate = Promise.withResolvers<undefined>()
|
||||||
const setupStarted = Promise.withResolvers<undefined>()
|
const setupStarted = Promise.withResolvers<undefined>()
|
||||||
const order: string[] = []
|
const order: string[] = []
|
||||||
ctx.on('session/created', (session) => {
|
ctx.on('session/created', (session) => {
|
||||||
expect(ctx.sessions.get(session.id)).toBe(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')
|
order.push('session/created')
|
||||||
})
|
})
|
||||||
ctx.on('agent/created', () => void order.push('agent/created'))
|
ctx.on('agent/created', () => void order.push('agent/created'))
|
||||||
@@ -239,11 +235,10 @@ describe('agent scope lifecycle', () => {
|
|||||||
const acceptedOptions = { model: 'mock' }
|
const acceptedOptions = { model: 'mock' }
|
||||||
|
|
||||||
const creating = ctx.agents.create({
|
const creating = ctx.agents.create({
|
||||||
agentId: AgentId('atomic'),
|
sessionId: SessionId('atomic'),
|
||||||
sessionId: SessionId('atomic-s'),
|
|
||||||
agentOptions: acceptedOptions,
|
agentOptions: acceptedOptions,
|
||||||
setup: async (agentCtx) => {
|
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('session/created', () => void order.push('setup-listener:session/created'))
|
||||||
agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created'))
|
agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created'))
|
||||||
order.push('setup:start')
|
order.push('setup:start')
|
||||||
@@ -253,7 +248,7 @@ describe('agent scope lifecycle', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
await setupStarted.promise
|
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(ctx.sessions.get(SessionId('atomic-s'))).toBeUndefined()
|
||||||
expect(order).toEqual(['setup:start'])
|
expect(order).toEqual(['setup:start'])
|
||||||
gate.resolve(undefined)
|
gate.resolve(undefined)
|
||||||
@@ -281,16 +276,14 @@ describe('agent scope lifecycle', () => {
|
|||||||
if (started === 2) bothStarted.resolve(undefined)
|
if (started === 2) bothStarted.resolve(undefined)
|
||||||
await gate.promise
|
await gate.promise
|
||||||
}
|
}
|
||||||
const agentId = AgentId('concurrent-final-enter')
|
const sessionId = SessionId('concurrent-final-enter')
|
||||||
const first = ctx.agents.create({
|
const first = ctx.agents.create({
|
||||||
agentId,
|
sessionId,
|
||||||
sessionId: SessionId('concurrent-final-enter-a'),
|
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
setup,
|
setup,
|
||||||
})
|
})
|
||||||
const second = ctx.agents.create({
|
const second = ctx.agents.create({
|
||||||
agentId,
|
sessionId,
|
||||||
sessionId: SessionId('concurrent-final-enter-b'),
|
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
setup,
|
setup,
|
||||||
})
|
})
|
||||||
@@ -304,7 +297,7 @@ describe('agent scope lifecycle', () => {
|
|||||||
const rejected = outcomes.filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected')
|
const rejected = outcomes.filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected')
|
||||||
expect(fulfilled).toHaveLength(1)
|
expect(fulfilled).toHaveLength(1)
|
||||||
expect(rejected).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.agents.list()).toEqual([fulfilled[0]!.value.agent])
|
||||||
expect(ctx.sessions.list()).toEqual([fulfilled[0]!.value.agent.session])
|
expect(ctx.sessions.list()).toEqual([fulfilled[0]!.value.agent.session])
|
||||||
|
|
||||||
@@ -318,7 +311,6 @@ describe('agent scope lifecycle', () => {
|
|||||||
const pendingController = new AbortController()
|
const pendingController = new AbortController()
|
||||||
const setupStarted = Promise.withResolvers<undefined>()
|
const setupStarted = Promise.withResolvers<undefined>()
|
||||||
const pending = ctx.agents.create({
|
const pending = ctx.agents.create({
|
||||||
agentId: AgentId('signal-pending'),
|
|
||||||
sessionId: SessionId('signal-pending-s'),
|
sessionId: SessionId('signal-pending-s'),
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
signal: pendingController.signal,
|
signal: pendingController.signal,
|
||||||
@@ -330,12 +322,11 @@ describe('agent scope lifecycle', () => {
|
|||||||
await setupStarted.promise
|
await setupStarted.promise
|
||||||
pendingController.abort(new Error('cancel pending creation'))
|
pendingController.abort(new Error('cancel pending creation'))
|
||||||
await expect(pending).rejects.toThrow('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()
|
expect(ctx.sessions.get(SessionId('signal-pending-s'))).toBeUndefined()
|
||||||
|
|
||||||
const liveController = new AbortController()
|
const liveController = new AbortController()
|
||||||
const live = await ctx.agents.create({
|
const live = await ctx.agents.create({
|
||||||
agentId: AgentId('signal-live'),
|
|
||||||
sessionId: SessionId('signal-live-s'),
|
sessionId: SessionId('signal-live-s'),
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
signal: liveController.signal,
|
signal: liveController.signal,
|
||||||
@@ -358,7 +349,6 @@ describe('agent scope lifecycle', () => {
|
|||||||
let creating!: ReturnType<typeof ctx.agents.create>
|
let creating!: ReturnType<typeof ctx.agents.create>
|
||||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||||
creating = inner.agents.create({
|
creating = inner.agents.create({
|
||||||
agentId: AgentId('owner-race'),
|
|
||||||
sessionId: SessionId('owner-race-s'),
|
sessionId: SessionId('owner-race-s'),
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
setup: async () => {
|
setup: async () => {
|
||||||
@@ -372,7 +362,7 @@ describe('agent scope lifecycle', () => {
|
|||||||
await owner.dispose()
|
await owner.dispose()
|
||||||
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
||||||
expect(published).toEqual([])
|
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()
|
expect(ctx.sessions.get(SessionId('owner-race-s'))).toBeUndefined()
|
||||||
// Let the losing callback settle; Promise.race already observes it.
|
// Let the losing callback settle; Promise.race already observes it.
|
||||||
gate.resolve(undefined)
|
gate.resolve(undefined)
|
||||||
@@ -386,7 +376,6 @@ describe('agent scope lifecycle', () => {
|
|||||||
let creating2!: ReturnType<typeof ctx.agents.create>
|
let creating2!: ReturnType<typeof ctx.agents.create>
|
||||||
const owner2 = await ctx.plugin(Object.assign((inner: Context) => {
|
const owner2 = await ctx.plugin(Object.assign((inner: Context) => {
|
||||||
creating2 = inner.agents.create({
|
creating2 = inner.agents.create({
|
||||||
agentId: AgentId('owner-race-2'),
|
|
||||||
sessionId: SessionId('owner-race-s-2'),
|
sessionId: SessionId('owner-race-s-2'),
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
setup: async () => {
|
setup: async () => {
|
||||||
@@ -400,7 +389,7 @@ describe('agent scope lifecycle', () => {
|
|||||||
const unload2 = owner2.dispose()
|
const unload2 = owner2.dispose()
|
||||||
await expect(creating2).rejects.toThrow(/owner disposed during setup/)
|
await expect(creating2).rejects.toThrow(/owner disposed during setup/)
|
||||||
await unload2
|
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()
|
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'))
|
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||||
|
|
||||||
const creating = ctx.agents.create({
|
const creating = ctx.agents.create({
|
||||||
agentId: AgentId('factory-setup-race'),
|
|
||||||
sessionId: SessionId('factory-setup-race-s'),
|
sessionId: SessionId('factory-setup-race-s'),
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
setup: async () => {
|
setup: async () => {
|
||||||
@@ -426,7 +414,7 @@ describe('agent scope lifecycle', () => {
|
|||||||
await loopFiber.dispose()
|
await loopFiber.dispose()
|
||||||
await expect(creating).rejects.toThrow(/agent loop is not active/)
|
await expect(creating).rejects.toThrow(/agent loop is not active/)
|
||||||
expect(published).toEqual([])
|
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()
|
expect(ctx.sessions.get(SessionId('factory-setup-race-s'))).toBeUndefined()
|
||||||
|
|
||||||
gate.resolve(undefined)
|
gate.resolve(undefined)
|
||||||
@@ -444,7 +432,6 @@ describe('agent scope lifecycle', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const creating = ctx.agents.create({
|
const creating = ctx.agents.create({
|
||||||
agentId: AgentId('factory-scope-race'),
|
|
||||||
sessionId: SessionId('factory-scope-race-s'),
|
sessionId: SessionId('factory-scope-race-s'),
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
setup: () => { setupCalls += 1 },
|
setup: () => { setupCalls += 1 },
|
||||||
@@ -452,7 +439,7 @@ describe('agent scope lifecycle', () => {
|
|||||||
await expect(creating).rejects.toThrow(/agent loop is not active/)
|
await expect(creating).rejects.toThrow(/agent loop is not active/)
|
||||||
await loopFiber.dispose()
|
await loopFiber.dispose()
|
||||||
expect(setupCalls).toBe(0)
|
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()
|
expect(ctx.sessions.get(SessionId('factory-scope-race-s'))).toBeUndefined()
|
||||||
|
|
||||||
await ctx.fiber.dispose()
|
await ctx.fiber.dispose()
|
||||||
@@ -479,7 +466,6 @@ describe('agent scope lifecycle', () => {
|
|||||||
const owner = ctx.plugin(Object.assign((inner: Context) => {
|
const owner = ctx.plugin(Object.assign((inner: Context) => {
|
||||||
ownerFiber = inner.fiber
|
ownerFiber = inner.fiber
|
||||||
creating = inner.agents.create({
|
creating = inner.agents.create({
|
||||||
agentId: AgentId('caller-scope-race'),
|
|
||||||
sessionId: SessionId('caller-scope-race-s'),
|
sessionId: SessionId('caller-scope-race-s'),
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
})
|
})
|
||||||
@@ -495,7 +481,7 @@ describe('agent scope lifecycle', () => {
|
|||||||
await ownerDisposal
|
await ownerDisposal
|
||||||
await owner
|
await owner
|
||||||
expect(scopeFiber?.uid).toBeNull()
|
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()
|
expect(ctx.sessions.get(SessionId('caller-scope-race-s'))).toBeUndefined()
|
||||||
await owner.dispose()
|
await owner.dispose()
|
||||||
await ctx.fiber.dispose()
|
await ctx.fiber.dispose()
|
||||||
@@ -511,17 +497,17 @@ describe('agent scope lifecycle', () => {
|
|||||||
void loopFiber.dispose()
|
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/)
|
.toThrow(/agent loop is not active/)
|
||||||
await loopFiber.dispose()
|
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)
|
expect(ctx.sessions.list()).toHaveLength(sessionsBefore)
|
||||||
await ctx.fiber.dispose()
|
await ctx.fiber.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('synchronous create leaves no lifecycle state when session preparation fails', async () => {
|
it('synchronous create leaves no lifecycle state when session preparation fails', async () => {
|
||||||
const ctx = await harness()
|
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' }))
|
expect(() => ctx.agentLoop.create(id, { model: 'mock' }, { cwd: 'relative' }))
|
||||||
.toThrow(/absolute path/)
|
.toThrow(/absolute path/)
|
||||||
@@ -542,12 +528,11 @@ describe('agent scope lifecycle', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await expect(ctx.agents.create({
|
await expect(ctx.agents.create({
|
||||||
agentId: AgentId('factory-scope-throw'),
|
|
||||||
sessionId: SessionId('factory-scope-throw-s'),
|
sessionId: SessionId('factory-scope-throw-s'),
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
})).rejects.toThrow('scope preparation failed')
|
})).rejects.toThrow('scope preparation failed')
|
||||||
await loopFiber.dispose()
|
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()
|
expect(ctx.sessions.get(SessionId('factory-scope-throw-s'))).toBeUndefined()
|
||||||
|
|
||||||
await ctx.fiber.dispose()
|
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 () => {
|
it('AgentLoop unload is a structural co-owner of every live programmatic agent', async () => {
|
||||||
const { ctx, loopFiber } = await harnessWithLoop()
|
const { ctx, loopFiber } = await harnessWithLoop()
|
||||||
const loop = ctx.agentLoop
|
const loop = ctx.agentLoop
|
||||||
const agentId = AgentId('factory-live')
|
const sessionId = SessionId('factory-live')
|
||||||
const handle = await ctx.agents.create({
|
const handle = await ctx.agents.create({
|
||||||
agentId,
|
sessionId,
|
||||||
sessionId: SessionId('factory-live-s'),
|
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
})
|
})
|
||||||
|
|
||||||
await loopFiber.dispose()
|
await loopFiber.dispose()
|
||||||
expect(handle.agent.status).toBe('disposed')
|
expect(handle.agent.status).toBe('disposed')
|
||||||
expect(ctx.agents.get(agentId)).toBeUndefined()
|
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||||
expect(ctx.sessions.get(SessionId('factory-live-s'))).toBeUndefined()
|
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||||
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${agentId})`)).toEqual([])
|
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${sessionId})`)).toEqual([])
|
||||||
// The consumer handle shares the provider's completed quiescence boundary.
|
// The consumer handle shares the provider's completed quiescence boundary.
|
||||||
await handle.dispose()
|
await handle.dispose()
|
||||||
|
|
||||||
await expect(loop.createAgent(ctx, {
|
await expect(loop.createAgent(ctx, {
|
||||||
agentId: AgentId('factory-inactive'),
|
|
||||||
sessionId: SessionId('factory-inactive-s'),
|
sessionId: SessionId('factory-inactive-s'),
|
||||||
})).rejects.toThrow('agent loop is not active')
|
})).rejects.toThrow('agent loop is not active')
|
||||||
await ctx.fiber.dispose()
|
await ctx.fiber.dispose()
|
||||||
@@ -583,7 +566,6 @@ describe('agent scope lifecycle', () => {
|
|||||||
let creating!: ReturnType<typeof ctx.agents.create>
|
let creating!: ReturnType<typeof ctx.agents.create>
|
||||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||||
creating = inner.agents.create({
|
creating = inner.agents.create({
|
||||||
agentId: AgentId('dependency-origin'),
|
|
||||||
sessionId: SessionId('dependency-origin-s'),
|
sessionId: SessionId('dependency-origin-s'),
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
setup: (agentCtx) => {
|
setup: (agentCtx) => {
|
||||||
@@ -623,7 +605,7 @@ describe('agent scope lifecycle', () => {
|
|||||||
})
|
})
|
||||||
ctx.on('session/created', (session) => {
|
ctx.on('session/created', (session) => {
|
||||||
if (session.id !== SessionId('session-created-barrier-s')) return
|
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(ctx.sessions.get(session.id)).toBe(session)
|
||||||
expect(agent.session).toBe(session)
|
expect(agent.session).toBe(session)
|
||||||
agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') })
|
agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') })
|
||||||
@@ -638,7 +620,6 @@ describe('agent scope lifecycle', () => {
|
|||||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||||
ownerCtx = inner
|
ownerCtx = inner
|
||||||
creating = inner.agents.create({
|
creating = inner.agents.create({
|
||||||
agentId: AgentId('session-created-barrier'),
|
|
||||||
sessionId: SessionId('session-created-barrier-s'),
|
sessionId: SessionId('session-created-barrier-s'),
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
})
|
})
|
||||||
@@ -652,7 +633,7 @@ describe('agent scope lifecycle', () => {
|
|||||||
'session-disposed',
|
'session-disposed',
|
||||||
'scope-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()
|
expect(ctx.sessions.get(SessionId('session-created-barrier-s'))).toBeUndefined()
|
||||||
await ctx.fiber.dispose()
|
await ctx.fiber.dispose()
|
||||||
})
|
})
|
||||||
@@ -666,19 +647,19 @@ describe('agent scope lifecycle', () => {
|
|||||||
if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-created')
|
if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-created')
|
||||||
})
|
})
|
||||||
ctx.on('agent/created', (agent) => {
|
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')
|
lifecycle.push('agent-created:dispose')
|
||||||
disposeCurrentLifecycle(ownerCtx)
|
disposeCurrentLifecycle(ownerCtx)
|
||||||
})
|
})
|
||||||
ctx.on('agent/created', (agent) => {
|
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.agents.get(agent.id)).toBe(agent)
|
||||||
expect(ctx.sessions.get(agent.session.id)).toBe(agent.session)
|
expect(ctx.sessions.get(agent.session.id)).toBe(agent.session)
|
||||||
agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') })
|
agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') })
|
||||||
lifecycle.push('agent-created:observer')
|
lifecycle.push('agent-created:observer')
|
||||||
})
|
})
|
||||||
ctx.on('agent/disposed', (agent) => {
|
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) => {
|
ctx.on('session/disposed', (session) => {
|
||||||
if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-disposed')
|
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) => {
|
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||||
ownerCtx = inner
|
ownerCtx = inner
|
||||||
creating = inner.agents.create({
|
creating = inner.agents.create({
|
||||||
agentId: AgentId('agent-created-barrier'),
|
|
||||||
sessionId: SessionId('agent-created-barrier-s'),
|
sessionId: SessionId('agent-created-barrier-s'),
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
})
|
})
|
||||||
@@ -703,7 +683,7 @@ describe('agent scope lifecycle', () => {
|
|||||||
'session-disposed',
|
'session-disposed',
|
||||||
'scope-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()
|
expect(ctx.sessions.get(SessionId('agent-created-barrier-s'))).toBeUndefined()
|
||||||
await ctx.fiber.dispose()
|
await ctx.fiber.dispose()
|
||||||
})
|
})
|
||||||
@@ -715,13 +695,12 @@ describe('agent scope lifecycle', () => {
|
|||||||
let creating!: ReturnType<typeof ctx.agents.create>
|
let creating!: ReturnType<typeof ctx.agents.create>
|
||||||
ctx.on('agent/session-start', agent => void starts.push(agent.id))
|
ctx.on('agent/session-start', agent => void starts.push(agent.id))
|
||||||
ctx.on('agent/created', (agent) => {
|
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) => {
|
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||||
ownerCtx = inner
|
ownerCtx = inner
|
||||||
creating = inner.agents.create({
|
creating = inner.agents.create({
|
||||||
agentId: AgentId('listener-dispose'),
|
|
||||||
sessionId: SessionId('listener-dispose-s'),
|
sessionId: SessionId('listener-dispose-s'),
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
})
|
})
|
||||||
@@ -730,7 +709,7 @@ describe('agent scope lifecycle', () => {
|
|||||||
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
||||||
await owner.dispose()
|
await owner.dispose()
|
||||||
expect(starts).toEqual([])
|
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()
|
expect(ctx.sessions.get(SessionId('listener-dispose-s'))).toBeUndefined()
|
||||||
await ctx.fiber.dispose()
|
await ctx.fiber.dispose()
|
||||||
})
|
})
|
||||||
@@ -744,15 +723,15 @@ describe('agent scope lifecycle', () => {
|
|||||||
let scopeDisposed = false
|
let scopeDisposed = false
|
||||||
let observerSawLive = false
|
let observerSawLive = false
|
||||||
ctx.on('agent/status', (agent, status) => {
|
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) => {
|
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
|
announced = agent as ReactLoopAgent
|
||||||
disposeCurrentLifecycle(ownerCtx)
|
disposeCurrentLifecycle(ownerCtx)
|
||||||
})
|
})
|
||||||
ctx.on('agent/session-start', (agent) => {
|
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.agents.get(agent.id)).toBe(agent)
|
||||||
expect(ctx.sessions.get(agent.session.id)).toBe(agent.session)
|
expect(ctx.sessions.get(agent.session.id)).toBe(agent.session)
|
||||||
agent.ctx.effect(() => () => { scopeDisposed = true })
|
agent.ctx.effect(() => () => { scopeDisposed = true })
|
||||||
@@ -762,7 +741,6 @@ describe('agent scope lifecycle', () => {
|
|||||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||||
ownerCtx = inner
|
ownerCtx = inner
|
||||||
creating = inner.agents.create({
|
creating = inner.agents.create({
|
||||||
agentId: AgentId('session-start-dispose'),
|
|
||||||
sessionId: SessionId('session-start-dispose-s'),
|
sessionId: SessionId('session-start-dispose-s'),
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
})
|
})
|
||||||
@@ -775,7 +753,7 @@ describe('agent scope lifecycle', () => {
|
|||||||
expect(observerSawLive).toBe(true)
|
expect(observerSawLive).toBe(true)
|
||||||
expect(scopeDisposed).toBe(true)
|
expect(scopeDisposed).toBe(true)
|
||||||
expect(announced.session.events).toEqual([])
|
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()
|
expect(ctx.sessions.get(SessionId('session-start-dispose-s'))).toBeUndefined()
|
||||||
await ctx.fiber.dispose()
|
await ctx.fiber.dispose()
|
||||||
})
|
})
|
||||||
@@ -787,7 +765,6 @@ describe('agent scope lifecycle', () => {
|
|||||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||||
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
|
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
|
||||||
await expect(ctx.agents.create({
|
await expect(ctx.agents.create({
|
||||||
agentId: AgentId('bad'),
|
|
||||||
sessionId: SessionId('bad-s'),
|
sessionId: SessionId('bad-s'),
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
setup: async () => {
|
setup: async () => {
|
||||||
@@ -798,13 +775,13 @@ describe('agent scope lifecycle', () => {
|
|||||||
|
|
||||||
// Nothing leaked: no agent, no session, and the ids are reusable.
|
// Nothing leaked: no agent, no session, and the ids are reusable.
|
||||||
expect(published).toEqual([])
|
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()
|
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()
|
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 ctx = await harness()
|
||||||
const published: string[] = []
|
const published: string[] = []
|
||||||
ctx.on('session/created', () => { published.push('session') })
|
ctx.on('session/created', () => { published.push('session') })
|
||||||
@@ -817,17 +794,15 @@ describe('agent scope lifecycle', () => {
|
|||||||
}] as unknown as SessionEvent[]
|
}] as unknown as SessionEvent[]
|
||||||
|
|
||||||
await expect(ctx.agents.create({
|
await expect(ctx.agents.create({
|
||||||
agentId: AgentId('exotic-seed'),
|
|
||||||
sessionId: SessionId('exotic-seed-session'),
|
sessionId: SessionId('exotic-seed-session'),
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
seed,
|
seed,
|
||||||
})).rejects.toThrow(/seed event at index 0 is not losslessly JSON-serializable/)
|
})).rejects.toThrow(/seed event at index 0 is not losslessly JSON-serializable/)
|
||||||
|
|
||||||
expect(published).toEqual([])
|
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()
|
expect(ctx.sessions.get(SessionId('exotic-seed-session'))).toBeUndefined()
|
||||||
const retry = await ctx.agents.create({
|
const retry = await ctx.agents.create({
|
||||||
agentId: AgentId('exotic-seed'),
|
|
||||||
sessionId: SessionId('exotic-seed-session'),
|
sessionId: SessionId('exotic-seed-session'),
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
})
|
})
|
||||||
@@ -843,13 +818,13 @@ describe('agent scope lifecycle', () => {
|
|||||||
if (boom) { boom = false; throw new Error('boom created') }
|
if (boom) { boom = false; throw new Error('boom created') }
|
||||||
})
|
})
|
||||||
await expect(ctx.agents.create({
|
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')
|
})).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(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined()
|
||||||
expect(disposed).toEqual([]) // inserted but never announced: no impossible disposed edge
|
expect(disposed).toEqual([]) // inserted but never announced: no impossible disposed edge
|
||||||
// The rollback also disposed the scope fiber: re-creating works cleanly.
|
// 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)
|
expect(scopeOf(retry.agent.ctx)).toBe(retry.agent)
|
||||||
await retry.dispose()
|
await retry.dispose()
|
||||||
})
|
})
|
||||||
@@ -866,18 +841,17 @@ describe('agent scope lifecycle', () => {
|
|||||||
ctx.on('agent/disposed', (agent) => { lifecycle.push(`agent-disposed:${agent.id}`) })
|
ctx.on('agent/disposed', (agent) => { lifecycle.push(`agent-disposed:${agent.id}`) })
|
||||||
|
|
||||||
await expect(ctx.agents.create({
|
await expect(ctx.agents.create({
|
||||||
agentId: AgentId('partial-agent'),
|
|
||||||
sessionId: SessionId('partial-session'),
|
sessionId: SessionId('partial-session'),
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
})).rejects.toThrow('agent observer failed')
|
})).rejects.toThrow('agent observer failed')
|
||||||
|
|
||||||
expect(lifecycle).toEqual([
|
expect(lifecycle).toEqual([
|
||||||
'session-created:partial-session',
|
'session-created:partial-session',
|
||||||
'agent-created:partial-agent',
|
'agent-created:partial-session',
|
||||||
'agent-disposed:partial-agent',
|
'agent-disposed:partial-session',
|
||||||
'session-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()
|
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')
|
.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)
|
expect(ctx.sessions.list()).toHaveLength(sessionsBefore)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('registrations through a disposed agent ctx throw INACTIVE_EFFECT', async () => {
|
it('registrations through a disposed agent ctx throw INACTIVE_EFFECT', async () => {
|
||||||
const ctx = await harness()
|
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()
|
await handle.dispose()
|
||||||
expect(() => handle.agent.ctx.on('agent/status', () => {})).toThrow(/inactive context/)
|
expect(() => handle.agent.ctx.on('agent/status', () => {})).toThrow(/inactive context/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('agentEvents fuses carrier and subject for custom drivers', async () => {
|
it('agentEvents fuses carrier and subject for custom drivers', async () => {
|
||||||
const ctx = await harness()
|
const ctx = await harness()
|
||||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
|
||||||
const other = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' })
|
const other = ctx.agentLoop.create(SessionId('a2'), { model: 'mock' })
|
||||||
const heard: string[] = []
|
const heard: string[] = []
|
||||||
agent.ctx.on('agent/error', (subject: Agent, turn: number) => void heard.push(`${subject.id}:${turn}`))
|
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()
|
const ctx = await harness()
|
||||||
let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
|
let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
|
||||||
const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
|
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'] }))
|
}, { inject: ['agents'] }))
|
||||||
const { agent } = handle
|
const { agent } = handle
|
||||||
|
|
||||||
@@ -930,7 +904,7 @@ describe('agent scope lifecycle', () => {
|
|||||||
if (event.type === 'turn/end') order.push('turn-end')
|
if (event.type === 'turn/end') order.push('turn-end')
|
||||||
})
|
})
|
||||||
ctx.on('agent/disposed', () => {
|
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}`)
|
order.push(`session-still-stored=${ctx.sessions.get(SessionId('o1-s')) !== undefined}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -955,7 +929,7 @@ describe('agent scope lifecycle', () => {
|
|||||||
const ctx = await harness()
|
const ctx = await harness()
|
||||||
let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
|
let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
|
||||||
const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
|
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'] }))
|
}, { inject: ['agents'] }))
|
||||||
|
|
||||||
const teardownDone: string[] = []
|
const teardownDone: string[] = []
|
||||||
@@ -967,23 +941,22 @@ describe('agent scope lifecycle', () => {
|
|||||||
// actually finished (the raw wrapper returns undefined on a repeat call).
|
// actually finished (the raw wrapper returns undefined on a repeat call).
|
||||||
await handle.dispose()
|
await handle.dispose()
|
||||||
expect(teardownDone).toContain('unregistered')
|
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()
|
expect(ctx.sessions.get(SessionId('h1-s'))).toBeUndefined()
|
||||||
await unload
|
await unload
|
||||||
})
|
})
|
||||||
|
|
||||||
it('successful handle disposal retires its caller ownership effect', async () => {
|
it('successful handle disposal retires its caller ownership effect', async () => {
|
||||||
const ctx = await harness()
|
const ctx = await harness()
|
||||||
const agentId = AgentId('retired-owner-effect')
|
const sessionId = SessionId('retired-owner-effect')
|
||||||
const handle = await ctx.agents.create({
|
const handle = await ctx.agents.create({
|
||||||
agentId,
|
sessionId,
|
||||||
sessionId: SessionId('retired-owner-effect-s'),
|
|
||||||
agentOptions: { model: 'mock' },
|
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()
|
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()
|
await ctx.fiber.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -994,7 +967,6 @@ describe('agent scope lifecycle', () => {
|
|||||||
let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
|
let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
|
||||||
const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
|
const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
|
||||||
handle = await inner.agents.create({
|
handle = await inner.agents.create({
|
||||||
agentId: AgentId('manual-first'),
|
|
||||||
sessionId: SessionId('manual-first-s'),
|
sessionId: SessionId('manual-first-s'),
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
setup(agentCtx) {
|
setup(agentCtx) {
|
||||||
@@ -1014,7 +986,7 @@ describe('agent scope lifecycle', () => {
|
|||||||
expect(ownerSettled).toBe(false)
|
expect(ownerSettled).toBe(false)
|
||||||
gate.resolve(undefined)
|
gate.resolve(undefined)
|
||||||
await Promise.all([disposing, unloading])
|
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()
|
expect(ctx.sessions.get(SessionId('manual-first-s'))).toBeUndefined()
|
||||||
await ctx.fiber.dispose()
|
await ctx.fiber.dispose()
|
||||||
})
|
})
|
||||||
@@ -1024,13 +996,11 @@ describe('agent scope lifecycle', () => {
|
|||||||
const gate = Promise.withResolvers<undefined>()
|
const gate = Promise.withResolvers<undefined>()
|
||||||
const cleanupStarted = Promise.withResolvers<undefined>()
|
const cleanupStarted = Promise.withResolvers<undefined>()
|
||||||
const sessionDisposed = Promise.withResolvers<undefined>()
|
const sessionDisposed = Promise.withResolvers<undefined>()
|
||||||
const agentId = AgentId('quiescent-reuse')
|
const sessionId = SessionId('quiescent-reuse')
|
||||||
const sessionId = SessionId('quiescent-reuse-s')
|
|
||||||
ctx.on('session/disposed', (session) => {
|
ctx.on('session/disposed', (session) => {
|
||||||
if (session.id === sessionId) sessionDisposed.resolve(undefined)
|
if (session.id === sessionId) sessionDisposed.resolve(undefined)
|
||||||
})
|
})
|
||||||
const first = await ctx.agents.create({
|
const first = await ctx.agents.create({
|
||||||
agentId,
|
|
||||||
sessionId,
|
sessionId,
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
setup(agentCtx) {
|
setup(agentCtx) {
|
||||||
@@ -1043,10 +1013,10 @@ describe('agent scope lifecycle', () => {
|
|||||||
|
|
||||||
const disposing = first.dispose()
|
const disposing = first.dispose()
|
||||||
await Promise.all([sessionDisposed.promise, cleanupStarted.promise])
|
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()
|
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||||
const replacement = await ctx.agents.create({ agentId, sessionId, agentOptions: { model: 'mock' } })
|
const replacement = await ctx.agents.create({ sessionId, agentOptions: { model: 'mock' } })
|
||||||
expect(ctx.agents.get(agentId)).toBe(replacement.agent)
|
expect(ctx.agents.get(sessionId)).toBe(replacement.agent)
|
||||||
expect(ctx.sessions.get(sessionId)).toBe(replacement.agent.session)
|
expect(ctx.sessions.get(sessionId)).toBe(replacement.agent.session)
|
||||||
|
|
||||||
gate.resolve(undefined)
|
gate.resolve(undefined)
|
||||||
@@ -1058,7 +1028,6 @@ describe('agent scope lifecycle', () => {
|
|||||||
it('handle.dispose() awaits an idle-injection flush before unregistering or detaching', async () => {
|
it('handle.dispose() awaits an idle-injection flush before unregistering or detaching', async () => {
|
||||||
const ctx = await harness()
|
const ctx = await harness()
|
||||||
const handle = await ctx.agents.create({
|
const handle = await ctx.agents.create({
|
||||||
agentId: AgentId('idle-flush'),
|
|
||||||
sessionId: SessionId('idle-flush-s'),
|
sessionId: SessionId('idle-flush-s'),
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
})
|
})
|
||||||
@@ -1077,12 +1046,12 @@ describe('agent scope lifecycle', () => {
|
|||||||
const disposal = handle.dispose().then(() => { disposed = true })
|
const disposal = handle.dispose().then(() => { disposed = true })
|
||||||
await new Promise(resolve => setTimeout(resolve, 0))
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
expect(disposed).toBe(false)
|
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)
|
expect(ctx.sessions.get(SessionId('idle-flush-s'))).toBe(handle.agent.session)
|
||||||
|
|
||||||
gate.resolve(undefined)
|
gate.resolve(undefined)
|
||||||
await disposal
|
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()
|
expect(ctx.sessions.get(SessionId('idle-flush-s'))).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,11 +10,12 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import LlmService from '@deepseek-ai/dsh-llm'
|
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 SystemPrompt, { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
||||||
import type { Config as SystemPromptConfig } 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 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 AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
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 adapter = new MockAdapter([textResponse('done')])
|
||||||
const ctx = await harness(adapter, toolOrder)
|
const ctx = await harness(adapter, toolOrder)
|
||||||
for (const name of registrationOrder) registerNamed(ctx, name)
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
return { ctx, agent, adapter }
|
return { ctx, agent, adapter }
|
||||||
@@ -103,7 +104,7 @@ describe('loop-level canonical tool order', () => {
|
|||||||
registerNamed(ctx, 'alpha')
|
registerNamed(ctx, 'alpha')
|
||||||
const errors: Error[] = []
|
const errors: Error[] = []
|
||||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
expect(adapter.requests).toHaveLength(0)
|
expect(adapter.requests).toHaveLength(0)
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import LlmService from '@deepseek-ai/dsh-llm'
|
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 SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
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 AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||||
@@ -45,7 +46,7 @@ describe('agent/turn-stop', () => {
|
|||||||
textResponse('must not be requested'),
|
textResponse('must not be requested'),
|
||||||
])
|
])
|
||||||
const ctx = await harness(adapter)
|
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' }))
|
agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
|
||||||
|
|
||||||
let steered = false
|
let steered = false
|
||||||
@@ -72,7 +73,7 @@ describe('agent/turn-stop', () => {
|
|||||||
textResponse('must not become a late-steering turn'),
|
textResponse('must not become a late-steering turn'),
|
||||||
])
|
])
|
||||||
const ctx = await harness(adapter)
|
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' }))
|
agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
|
||||||
|
|
||||||
let injected = false
|
let injected = false
|
||||||
@@ -98,7 +99,7 @@ describe('agent/turn-stop', () => {
|
|||||||
textResponse('queued follow-up answer'),
|
textResponse('queued follow-up answer'),
|
||||||
])
|
])
|
||||||
const ctx = await harness(adapter)
|
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' }))
|
agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
|
||||||
|
|
||||||
let queued = false
|
let queued = false
|
||||||
@@ -124,8 +125,8 @@ describe('agent/turn-stop', () => {
|
|||||||
])
|
])
|
||||||
const ctx = await harness(adapter)
|
const ctx = await harness(adapter)
|
||||||
registerEcho(ctx)
|
registerEcho(ctx)
|
||||||
const stopped = ctx.agentLoop.create(AgentId('stopped'), { model: 'mock' })
|
const stopped = ctx.agentLoop.create(SessionId('stopped'), { model: 'mock' })
|
||||||
const ordinary = ctx.agentLoop.create(AgentId('ordinary'), { model: 'mock' })
|
const ordinary = ctx.agentLoop.create(SessionId('ordinary'), { model: 'mock' })
|
||||||
stopped.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
|
stopped.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
|
||||||
|
|
||||||
await send(stopped)
|
await send(stopped)
|
||||||
@@ -145,7 +146,7 @@ describe('agent/turn-stop', () => {
|
|||||||
])
|
])
|
||||||
const ctx = await harness(adapter)
|
const ctx = await harness(adapter)
|
||||||
registerEcho(ctx)
|
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' }))
|
const disposeStop = agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
|
||||||
|
|
||||||
await send(agent, 'first turn')
|
await send(agent, 'first turn')
|
||||||
@@ -162,7 +163,7 @@ describe('agent/turn-stop', () => {
|
|||||||
textResponse('healthy later turn'),
|
textResponse('healthy later turn'),
|
||||||
])
|
])
|
||||||
const ctx = await harness(adapter)
|
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 reasons: TurnEndReason[] = []
|
||||||
const errors: string[] = []
|
const errors: string[] = []
|
||||||
ctx.on('session/event', (session, event) => {
|
ctx.on('session/event', (session, event) => {
|
||||||
|
|||||||
@@ -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.
|
- `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()`.
|
- 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[]`
|
- `ctx.agents.list(): Agent[]`
|
||||||
|
|
||||||
#### Factory seam (creation)
|
#### Factory seam (creation)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Context, getTraceable, Service, symbols } from 'cordis'
|
|||||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
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 * from './types.ts'
|
||||||
export { agentEvents, assembleContextFor } from './dispatch.ts'
|
export { agentEvents, assembleContextFor } from './dispatch.ts'
|
||||||
@@ -33,15 +33,13 @@ declare module 'cordis' {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for programmatically creating an agent through the registry factory
|
* Options for programmatically creating an agent through the registry factory
|
||||||
* ({@link AgentRegistry.create}). The caller supplies the live `sessionId`
|
* ({@link AgentRegistry.create}). The caller supplies the single live
|
||||||
* (e.g. an ACP-generated id) and optional session metadata (the validated
|
* `sessionId` shared by the agent registry and session log (e.g. an
|
||||||
* `cwd`, fork lineage); the factory creates the session, the agent, and wires
|
* ACP-generated id), plus optional session metadata (the validated `cwd`, fork
|
||||||
* them together.
|
* lineage); the factory creates the session and agent under that identity.
|
||||||
*/
|
*/
|
||||||
export interface CreateAgentOptions {
|
export interface CreateAgentOptions {
|
||||||
/** The agent's id (the registry handle). */
|
/** The live agent/session identity. */
|
||||||
readonly agentId: AgentId
|
|
||||||
/** The live session's id (NOT derived from agentId). */
|
|
||||||
readonly sessionId: SessionId
|
readonly sessionId: SessionId
|
||||||
/**
|
/**
|
||||||
* Session creation metadata: validated absolute `cwd`, `parentSession`
|
* Session creation metadata: validated absolute `cwd`, `parentSession`
|
||||||
@@ -93,9 +91,7 @@ export interface CreateAgentOptions {
|
|||||||
* ({@link AgentRegistry.resume}).
|
* ({@link AgentRegistry.resume}).
|
||||||
*/
|
*/
|
||||||
export interface ResumeAgentOptions {
|
export interface ResumeAgentOptions {
|
||||||
/** The agent's id (the registry handle). */
|
/** The persisted session id to load and use as the live agent/session identity. */
|
||||||
readonly agentId: AgentId
|
|
||||||
/** The persisted session id to load and resume on. */
|
|
||||||
readonly resumeSessionId: SessionId
|
readonly resumeSessionId: SessionId
|
||||||
/** Per-agent options (model, …). */
|
/** Per-agent options (model, …). */
|
||||||
readonly agentOptions?: AgentOptions
|
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. */
|
/** All mutable lifecycle state for one exact registry entry. */
|
||||||
interface AgentEntry {
|
interface AgentEntry {
|
||||||
readonly id: AgentId
|
readonly id: SessionId
|
||||||
readonly agent: Agent
|
readonly agent: Agent
|
||||||
readonly carrier: Scoped<Agent>
|
readonly carrier: Scoped<Agent>
|
||||||
announced: boolean
|
announced: boolean
|
||||||
@@ -201,7 +197,7 @@ interface FactorySlot {
|
|||||||
* {@link setFactory}.
|
* {@link setFactory}.
|
||||||
*/
|
*/
|
||||||
export class AgentRegistry extends Service {
|
export class AgentRegistry extends Service {
|
||||||
private store = new Map<AgentId, AgentEntry>()
|
private store = new Map<SessionId, AgentEntry>()
|
||||||
private factory: FactorySlot | undefined
|
private factory: FactorySlot | undefined
|
||||||
|
|
||||||
constructor(ctx: Context) {
|
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
|
* agent): this constructs the agent and its session. Rejects if no factory is
|
||||||
* registered or creation/setup fails. The resolved {@link AgentHandle} lets
|
* registered or creation/setup fails. The resolved {@link AgentHandle} lets
|
||||||
* the owner tear down exactly this agent.
|
* 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.
|
* @returns the handle after setup, rollback-covered publication, and loop start complete.
|
||||||
*/
|
*/
|
||||||
async create(options: CreateAgentOptions): Promise<AgentHandle> {
|
async create(options: CreateAgentOptions): Promise<AgentHandle> {
|
||||||
@@ -428,10 +424,10 @@ export class AgentRegistry extends Service {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Look up a live agent.
|
* 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.
|
* @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
|
return this.store.get(id)?.agent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
* event. A turn/step boundary is a durable fact: it lives in the session log
|
* 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/*`
|
* 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
|
* 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`
|
* 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`.
|
* and `docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md`.
|
||||||
*
|
*
|
||||||
@@ -45,25 +45,12 @@
|
|||||||
* @module @deepseek-ai/dsh-agent/types
|
* @module @deepseek-ai/dsh-agent/types
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
|
||||||
import type { Context } from 'cordis'
|
import type { Context } from 'cordis'
|
||||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||||
import type { ContentBlock, LlmCallConfig, Message, MessageSource } from '@deepseek-ai/dsh-llm'
|
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'
|
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' {
|
declare module '@deepseek-ai/dsh-system-prompt' {
|
||||||
interface AssembleContext {
|
interface AssembleContext {
|
||||||
/**
|
/**
|
||||||
@@ -186,7 +173,8 @@ export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
|
|||||||
* package should depend on the implementation.
|
* package should depend on the implementation.
|
||||||
*/
|
*/
|
||||||
export interface Agent {
|
export interface Agent {
|
||||||
readonly id: AgentId
|
/** The single identity shared with {@link session}. */
|
||||||
|
readonly id: SessionId
|
||||||
readonly options: AgentOptions
|
readonly options: AgentOptions
|
||||||
readonly session: Session
|
readonly session: Session
|
||||||
readonly status: AgentStatus
|
readonly status: AgentStatus
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import { describe, expect, expectTypeOf, it } from 'vitest'
|
|||||||
import { Context, Service, symbols } from 'cordis'
|
import { Context, Service, symbols } from 'cordis'
|
||||||
import type { Events } from 'cordis'
|
import type { Events } from 'cordis'
|
||||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
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'
|
import type { Agent, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
|
||||||
|
|
||||||
function stubAgent(rawId: string): Agent {
|
function stubAgent(rawId: string): Agent {
|
||||||
const id = AgentId(rawId)
|
const id = SessionId(rawId)
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
options: {},
|
options: {},
|
||||||
@@ -57,7 +58,7 @@ describe('AgentRegistry', () => {
|
|||||||
ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`))
|
ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`))
|
||||||
|
|
||||||
expect(() => ctx.agents.register(stubAgent('vetoed'))).toThrow('creation veto')
|
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'])
|
expect(lifecycle).toEqual(['created:vetoed', 'disposed:vetoed'])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -158,11 +159,11 @@ describe('AgentRegistry factory seam', () => {
|
|||||||
const factory: AgentFactory = {
|
const factory: AgentFactory = {
|
||||||
async createAgent(ownerCtx, options) {
|
async createAgent(ownerCtx, options) {
|
||||||
calls.create.push({ 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) {
|
async resume(ownerCtx, options) {
|
||||||
calls.resume.push({ 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 }
|
return { factory, calls }
|
||||||
@@ -171,15 +172,15 @@ describe('AgentRegistry factory seam', () => {
|
|||||||
it('requires a factory and delegates through the calling context', async () => {
|
it('requires a factory and delegates through the calling context', async () => {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(AgentRegistry)
|
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()
|
const { factory, calls } = stubFactory()
|
||||||
ctx.agents.setFactory(factory)
|
ctx.agents.setFactory(factory)
|
||||||
|
|
||||||
let callerFiber: Context['fiber'] | undefined
|
let callerFiber: Context['fiber'] | undefined
|
||||||
await ctx.plugin(Object.assign(async (inner: Context) => {
|
await ctx.plugin(Object.assign(async (inner: Context) => {
|
||||||
callerFiber = inner.fiber
|
callerFiber = inner.fiber
|
||||||
await inner.agents.create({ agentId: AgentId('create'), sessionId: SessionId('create-s') })
|
await inner.agents.create({ sessionId: SessionId('create-s') })
|
||||||
await inner.agents.resume({ agentId: AgentId('resume'), resumeSessionId: SessionId('resume-s') })
|
await inner.agents.resume({ resumeSessionId: SessionId('resume-s') })
|
||||||
}, { inject: ['agents'] }))
|
}, { inject: ['agents'] }))
|
||||||
expect(calls.create[0]?.ownerCtx.fiber).toBe(callerFiber)
|
expect(calls.create[0]?.ownerCtx.fiber).toBe(callerFiber)
|
||||||
expect(calls.resume[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)
|
inner.agents.setFactory(stubFactory().factory)
|
||||||
expect(() => inner.agents.setFactory(stubFactory().factory)).toThrow(/already registered/)
|
expect(() => inner.agents.setFactory(stubFactory().factory)).toThrow(/already registered/)
|
||||||
}, { inject: ['agents'] }))
|
}, { 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 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 () => {
|
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) {
|
async createAgent(_ownerCtx: Context, options: CreateAgentOptions) {
|
||||||
this.calls().push('create')
|
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) {
|
async resume(_ownerCtx: Context, options: ResumeAgentOptions) {
|
||||||
this.calls().push('resume')
|
this.calls().push('resume')
|
||||||
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
|
return { agent: stubAgent(options.resumeSessionId), dispose: () => Promise.resolve() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await ctx.plugin(TracedFactory)
|
await ctx.plugin(TracedFactory)
|
||||||
const traced = (ctx as Context & { tracedFactory: TracedFactory }).tracedFactory
|
const traced = (ctx as Context & { tracedFactory: TracedFactory }).tracedFactory
|
||||||
ctx.agents.setFactory(traced)
|
ctx.agents.setFactory(traced)
|
||||||
await ctx.agents.create({ agentId: AgentId('create'), sessionId: SessionId('create-s') })
|
await ctx.agents.create({ sessionId: SessionId('create-s') })
|
||||||
await ctx.agents.resume({ agentId: AgentId('resume'), resumeSessionId: SessionId('resume-s') })
|
await ctx.agents.resume({ resumeSessionId: SessionId('resume-s') })
|
||||||
const raw = (traced as unknown as { [symbols.original]?: TracedFactory })[symbols.original]
|
const raw = (traced as unknown as { [symbols.original]?: TracedFactory })[symbols.original]
|
||||||
expect(states.get(raw!)).toEqual(['create', 'resume'])
|
expect(states.get(raw!)).toEqual(['create', 'resume'])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -288,7 +288,12 @@ export class Session {
|
|||||||
*/
|
*/
|
||||||
readonly header: SessionHeader
|
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) {
|
if (seed) {
|
||||||
// Validate the seed to the SAME invariants `append` enforces, so a
|
// Validate the seed to the SAME invariants `append` enforces, so a
|
||||||
// replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
|
// replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
|||||||
import type { CodeRunRequest, CodeRunResult } 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 ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools'
|
||||||
import type { Config, PostToolDecision, ToolExecutionResult } 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 type { Agent } from '@deepseek-ai/dsh-agent'
|
||||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import type { SessionEventMap } 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. */
|
/** Mint one production-shaped agent scope that can register scoped tool policy. */
|
||||||
async function mintAgentScope(ctx: Context, name = 'scoped'): Promise<{ scope: Scope; agent: Agent }> {
|
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
|
let scope!: Scope
|
||||||
await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) },
|
await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) },
|
||||||
{ inject: ['tools', 'systemPrompt'] }))
|
{ inject: ['tools', 'systemPrompt'] }))
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import type { Scope } from '@deepseek-ai/dsh-scope'
|
|||||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||||
import type { PreToolDecision, ToolDefinition, ToolExecution, ToolExecutionInput, ToolExecutionToken } 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 { CallId } from '@deepseek-ai/dsh-llm'
|
||||||
import type { ContentBlock } 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. */
|
/** Mount the registry (with its systemPrompt dependency) on a fresh context. */
|
||||||
async function mount(): Promise<Context> {
|
async function mount(): Promise<Context> {
|
||||||
@@ -20,7 +22,7 @@ async function mount(): Promise<Context> {
|
|||||||
|
|
||||||
/** Mint a scope whose key doubles as a minimal Agent-like object. */
|
/** Mint a scope whose key doubles as a minimal Agent-like object. */
|
||||||
async function mintAgentScope(ctx: Context, name: string): Promise<{ scope: Scope; key: Agent }> {
|
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
|
let scope!: Scope
|
||||||
// The scoped context resolves services through the MINTING plugin's
|
// The scoped context resolves services through the MINTING plugin's
|
||||||
// dependency chain — the minter must inject what scope holders will reach
|
// 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 () => {
|
it('files a scoped tool in its layer: visible/executable for that scope only', async () => {
|
||||||
const ctx = await mount()
|
const ctx = await mount()
|
||||||
const { scope, key } = await mintAgentScope(ctx, 'a')
|
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'))
|
ctx.tools.register(tool('shared'))
|
||||||
scope.ctx.tools.register(tool('mine'))
|
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 () => {
|
it('an agent.ctx pre-execute listener gates only its own agent (and never subject-less calls)', async () => {
|
||||||
const ctx = await mount()
|
const ctx = await mount()
|
||||||
const { scope, key } = await mintAgentScope(ctx, 'a')
|
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'))
|
ctx.tools.register(tool('t'))
|
||||||
|
|
||||||
const seen: (string | undefined)[] = []
|
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 () => {
|
it('applies scoped guards after pre-execute and unwinds duplicate registrations independently', async () => {
|
||||||
const ctx = await mount()
|
const ctx = await mount()
|
||||||
const { scope, key } = await mintAgentScope(ctx, 'a')
|
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
|
let bodyCalls = 0
|
||||||
ctx.tools.register({
|
ctx.tools.register({
|
||||||
...tool('t'),
|
...tool('t'),
|
||||||
@@ -428,7 +430,7 @@ describe('scoped execution dispatch', () => {
|
|||||||
it('uses one input snapshot for the normalized error shell', async () => {
|
it('uses one input snapshot for the normalized error shell', async () => {
|
||||||
const ctx = await mount()
|
const ctx = await mount()
|
||||||
const { scope, key } = await mintAgentScope(ctx, 'accepted')
|
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('parent'))
|
||||||
ctx.tools.register(tool('t'))
|
ctx.tools.register(tool('t'))
|
||||||
let parent!: ToolExecutionToken
|
let parent!: ToolExecutionToken
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { tmpdir } from 'node:os'
|
|||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
import type { Context } from 'cordis'
|
import type { Context } from 'cordis'
|
||||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
|
||||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import { fsHarness, waitForIdle } from './harness.ts'
|
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)
|
ctx = await fsHarness(workdir, SYSTEM)
|
||||||
// agentLoop.create prepares a session with no cwd, so the provider default
|
// agentLoop.create prepares a session with no cwd, so the provider default
|
||||||
// (config.cwd = workdir) is the workspace.
|
// (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:
|
agent.send([{ type: 'text', text:
|
||||||
'Create a file named note.txt containing exactly the line: status: draft. '
|
'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 {
|
try {
|
||||||
ctx = await fsHarness(configDir, SYSTEM)
|
ctx = await fsHarness(configDir, SYSTEM)
|
||||||
const handle = await ctx.agents.create({
|
const handle = await ctx.agents.create({
|
||||||
agentId: AgentId('fs-e2e-cwd'),
|
|
||||||
sessionId: SessionId(`fs-e2e-cwd-${Date.now()}`),
|
sessionId: SessionId(`fs-e2e-cwd-${Date.now()}`),
|
||||||
meta: { cwd: sessionDir },
|
meta: { cwd: sessionDir },
|
||||||
agentOptions: { model: 'deepseek-v4-flash' },
|
agentOptions: { model: 'deepseek-v4-flash' },
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
- **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.
|
- **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 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.
|
- **Per-agent keying.** The tool registry is context-level and subagents interleave through the same waterfall, so a `WeakMap<Agent, Chain>` 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.
|
- **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
|
## Reminder delivery
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
* exclude: [todo_write] # tool-name patterns transparent to the chain
|
* 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
|
* context-level singleton whose waterfalls interleave every agent's calls, so
|
||||||
* a shared counter would let one agent's repetition trip another's reminder.
|
* 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
|
* State is in-memory only: a session resumed from persistence starts with a
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
|
|
||||||
import type { Context } from 'cordis'
|
import type { Context } from 'cordis'
|
||||||
import z from 'schemastery'
|
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 { MessageSource } from '@deepseek-ai/dsh-llm'
|
||||||
import type { PostToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
|
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`)
|
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
|
const chains = new WeakMap<Agent, Chain>()
|
||||||
// removes the disposal-only status listener and cannot collide on id reuse.
|
|
||||||
const chains = new Map<AgentId, Chain>()
|
|
||||||
|
|
||||||
/** Whether a tool participates in the chain (untracked calls are transparent: they neither count nor reset). */
|
/** Whether a tool participates in the chain (untracked calls are transparent: they neither count nor reset). */
|
||||||
function tracked(toolName: string): boolean {
|
function tracked(toolName: string): boolean {
|
||||||
@@ -227,9 +225,9 @@ export function apply(ctx: Context, config: Config): void {
|
|||||||
if (!tracked(exec.name)) return undefined
|
if (!tracked(exec.name)) return undefined
|
||||||
const canonical = canonicalize(exec.arguments)
|
const canonical = canonicalize(exec.arguments)
|
||||||
const key = JSON.stringify([exec.name, canonical])
|
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
|
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
|
if (!thresholdSet.has(count)) return undefined
|
||||||
const text = count === thresholds[0]
|
const text = count === thresholds[0]
|
||||||
? GENTLE_REMINDER
|
? GENTLE_REMINDER
|
||||||
@@ -259,12 +257,7 @@ export function apply(ctx: Context, config: Config): void {
|
|||||||
// loop. Pure reset hook: always delegates (attaching nothing, vetoing
|
// loop. Pure reset hook: always delegates (attaching nothing, vetoing
|
||||||
// nothing).
|
// nothing).
|
||||||
ctx.on('agent/prompt-submit', (agent, _content, _source, next): Promise<PromptDecision> => {
|
ctx.on('agent/prompt-submit', (agent, _content, _source, next): Promise<PromptDecision> => {
|
||||||
chains.delete(agent.id)
|
chains.delete(agent)
|
||||||
return next()
|
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)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
|
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 SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
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 AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||||
import * as RepeatToolGuard from '@deepseek-ai/dsh-repeat-tool-guard'
|
import * as RepeatToolGuard from '@deepseek-ai/dsh-repeat-tool-guard'
|
||||||
import type { Config } 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'),
|
textResponse('done'),
|
||||||
])
|
])
|
||||||
ctx.llm.registerAdapter(['mock'], adapter)
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
@@ -78,7 +79,7 @@ describe('threshold escalation', () => {
|
|||||||
textResponse('done'),
|
textResponse('done'),
|
||||||
])
|
])
|
||||||
ctx.llm.registerAdapter(['mock'], adapter)
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
@@ -100,7 +101,7 @@ describe('chain semantics', () => {
|
|||||||
textResponse('done'),
|
textResponse('done'),
|
||||||
])
|
])
|
||||||
ctx.llm.registerAdapter(['mock'], adapter)
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
@@ -124,7 +125,7 @@ describe('chain semantics', () => {
|
|||||||
textResponse('done'),
|
textResponse('done'),
|
||||||
])
|
])
|
||||||
ctx.llm.registerAdapter(['mock'], adapter)
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
@@ -142,7 +143,7 @@ describe('chain semantics', () => {
|
|||||||
textResponse('done'),
|
textResponse('done'),
|
||||||
])
|
])
|
||||||
ctx.llm.registerAdapter(['mock'], adapter)
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
@@ -163,7 +164,7 @@ describe('chain semantics', () => {
|
|||||||
textResponse('done'),
|
textResponse('done'),
|
||||||
])
|
])
|
||||||
ctx.llm.registerAdapter(['mock'], adapter)
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
@@ -179,7 +180,7 @@ describe('chain semantics', () => {
|
|||||||
textResponse('done'),
|
textResponse('done'),
|
||||||
])
|
])
|
||||||
ctx.llm.registerAdapter(['mock'], adapter)
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
@@ -195,7 +196,7 @@ describe('chain semantics', () => {
|
|||||||
textResponse('done'),
|
textResponse('done'),
|
||||||
])
|
])
|
||||||
ctx.llm.registerAdapter(['mock'], adapter)
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
@@ -215,8 +216,8 @@ describe('chain semantics', () => {
|
|||||||
toolCallResponse('b3', 'probe', { q: 1 }),
|
toolCallResponse('b3', 'probe', { q: 1 }),
|
||||||
textResponse('done'),
|
textResponse('done'),
|
||||||
]))
|
]))
|
||||||
const agentA = ctx.agentLoop.create(AgentId('a'), { model: 'mock-a' })
|
const agentA = ctx.agentLoop.create(SessionId('a'), { model: 'mock-a' })
|
||||||
const agentB = ctx.agentLoop.create(AgentId('b'), { model: 'mock-b' })
|
const agentB = ctx.agentLoop.create(SessionId('b'), { model: 'mock-b' })
|
||||||
agentA.send([{ type: 'text', text: 'go' }])
|
agentA.send([{ type: 'text', text: 'go' }])
|
||||||
agentB.send([{ type: 'text', text: 'go' }])
|
agentB.send([{ type: 'text', text: 'go' }])
|
||||||
await Promise.all([waitForIdle(ctx, agentA), waitForIdle(ctx, agentB)])
|
await Promise.all([waitForIdle(ctx, agentA), waitForIdle(ctx, agentB)])
|
||||||
@@ -235,7 +236,7 @@ describe('chain semantics', () => {
|
|||||||
textResponse('turn two done'),
|
textResponse('turn two done'),
|
||||||
])
|
])
|
||||||
ctx.llm.registerAdapter(['mock'], adapter)
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
agent.send([{ type: 'text', text: 'again' }])
|
agent.send([{ type: 'text', text: 'again' }])
|
||||||
@@ -256,14 +257,14 @@ describe('chain semantics', () => {
|
|||||||
// (the loop.spec pattern): a child plugin fiber owns `first`.
|
// (the loop.spec pattern): a child plugin fiber owns `first`.
|
||||||
let first!: ReactLoopAgent
|
let first!: ReactLoopAgent
|
||||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
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'] }))
|
}, { inject: ['agentLoop'] }))
|
||||||
first.send([{ type: 'text', text: 'go' }])
|
first.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, first)
|
await waitForIdle(ctx, first)
|
||||||
await fiber.dispose()
|
await fiber.dispose()
|
||||||
await first.done
|
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' }])
|
second.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, second)
|
await waitForIdle(ctx, second)
|
||||||
|
|
||||||
@@ -279,7 +280,7 @@ describe('chain semantics', () => {
|
|||||||
textResponse('done'),
|
textResponse('done'),
|
||||||
])
|
])
|
||||||
ctx.llm.registerAdapter(['mock'], adapter)
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
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
|
toolCallResponse('c1', 'probe', { q: 1 }), // if the direct call had counted, this would be #2
|
||||||
textResponse('done'),
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
@@ -317,7 +318,7 @@ describe('fold onto the downstream decision', () => {
|
|||||||
textResponse('done'),
|
textResponse('done'),
|
||||||
])
|
])
|
||||||
ctx.llm.registerAdapter(['mock'], adapter)
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
@@ -348,7 +349,7 @@ describe('fold onto the downstream decision', () => {
|
|||||||
textResponse('done'),
|
textResponse('done'),
|
||||||
])
|
])
|
||||||
ctx.llm.registerAdapter(['mock'], adapter)
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import { join } from 'node:path'
|
|||||||
import { Context, type Fiber } from 'cordis'
|
import { Context, type Fiber } from 'cordis'
|
||||||
import Loader from '@cordisjs/plugin-loader'
|
import Loader from '@cordisjs/plugin-loader'
|
||||||
import LlmService from '@deepseek-ai/dsh-llm'
|
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 SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
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 AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||||
import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude'
|
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 adapter = new MockAdapter([textResponse('should not run')])
|
||||||
const ctx = await harness(dir, adapter)
|
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' }])
|
agent.send([{ type: 'text', text: 'do something' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
@@ -118,7 +119,7 @@ describe('hooks-claude bridge — UserPromptSubmit', () => {
|
|||||||
|
|
||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(dir, adapter)
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
@@ -143,7 +144,7 @@ describe('hooks-claude bridge — PreToolUse', () => {
|
|||||||
const ctx = await harness(dir, adapter)
|
const ctx = await harness(dir, adapter)
|
||||||
let ran = false
|
let ran = false
|
||||||
ctx.tools.register(defineTool({ name: 'danger', description: 'd', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] } }))
|
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' }])
|
agent.send([{ type: 'text', text: 'use danger' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
@@ -166,7 +167,7 @@ describe('hooks-claude bridge — PreToolUse', () => {
|
|||||||
const ctx = await harness(dir, adapter)
|
const ctx = await harness(dir, adapter)
|
||||||
let ran = false
|
let ran = false
|
||||||
ctx.tools.register(defineTool({ name: 'safe', description: 's', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ran ok' }] } }))
|
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' }])
|
agent.send([{ type: 'text', text: 'use safe' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
@@ -188,7 +189,7 @@ describe('hooks-claude bridge — PostToolUse', () => {
|
|||||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||||
const ctx = await harness(dir, adapter)
|
const ctx = await harness(dir, adapter)
|
||||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'raw output' }] } }))
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
@@ -209,7 +210,7 @@ describe('hooks-claude bridge — PostToolUse', () => {
|
|||||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||||
const ctx = await harness(dir, adapter)
|
const ctx = await harness(dir, adapter)
|
||||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
@@ -233,7 +234,7 @@ describe('hooks-claude bridge — PostToolUse', () => {
|
|||||||
const ctx = await harness(dir, adapter)
|
const ctx = await harness(dir, adapter)
|
||||||
let ran = false
|
let ran = false
|
||||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
@@ -257,7 +258,7 @@ describe('hooks-claude bridge — SessionStart', () => {
|
|||||||
|
|
||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(dir, adapter)
|
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
|
// session-start fires async (detached .then → agent.inject); wait for the
|
||||||
// injected context/message to actually land before sending, rather than a
|
// injected context/message to actually land before sending, rather than a
|
||||||
// fixed sleep that flakes under load.
|
// 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
|
// Drive the observe-only lifecycle events directly (no real child needed — the
|
||||||
// bridge just listens). No child agent is registered, so SubagentStart's
|
// bridge just listens). No child agent is registered, so SubagentStart's
|
||||||
// child lookup yields undefined and it simply runs the hook.
|
// child lookup yields undefined and it simply runs the hook.
|
||||||
ctx.emit('subagent/start', { provider: 'inproc', id: AgentId('child-1') })
|
ctx.emit('subagent/start', { provider: 'inproc', id: SessionId('child-1') })
|
||||||
ctx.emit('subagent/end', { provider: 'inproc', id: AgentId('child-1'), stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] })
|
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
|
// Both hooks run async (detached .then); poll for their marker files rather
|
||||||
// than a fixed sleep that flakes under load.
|
// 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 { ctx, hooks } = await harnessWithFiber(dir, new MockAdapter([]))
|
||||||
const warn = vi.fn()
|
const warn = vi.fn()
|
||||||
ctx.logger.warn = warn as never
|
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))
|
await waitFor(() => existsSync(marker))
|
||||||
const pid = Number(readFileSync(pidFile, 'utf8').trim())
|
const pid = Number(readFileSync(pidFile, 'utf8').trim())
|
||||||
await hooks.dispose()
|
await hooks.dispose()
|
||||||
@@ -359,7 +360,7 @@ describe('hooks-claude bridge — load resilience', () => {
|
|||||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||||
await ctx.plugin(HooksClaude, { configPath: '/nonexistent/hooks.json' })
|
await ctx.plugin(HooksClaude, { configPath: '/nonexistent/hooks.json' })
|
||||||
ctx.llm.registerAdapter(['mock'], adapter)
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
// The turn ran normally — no hooks, no crash.
|
// 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') })
|
const fiber = await ctx.plugin(HooksClaude, { configPath: join(dir, 'hooks.json') })
|
||||||
await fiber.dispose()
|
await fiber.dispose()
|
||||||
ctx.llm.registerAdapter(['mock'], adapter)
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone
|
expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import { tmpdir } from 'node:os'
|
|||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import LlmService from '@deepseek-ai/dsh-llm'
|
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 SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
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 AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||||
import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude'
|
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 })
|
const ctx = await harness(path, adapter, { pluginRoot: d, projectDir: d })
|
||||||
ctx.logger.warn = warn as never
|
ctx.logger.warn = warn as never
|
||||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
expect(existsSync(marker)).toBe(true) // substituted command ran
|
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
|
ctx.logger.warn = warn as never
|
||||||
let sawArgs: unknown
|
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' }] } }))
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
// updatedInput is NOT honored — the tool ran with the ORIGINAL args.
|
// 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 path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||||
const adapter = new MockAdapter([textResponse('ran')])
|
const adapter = new MockAdapter([textResponse('ran')])
|
||||||
const ctx = await harness(path, adapter)
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
// The prompt proceeded unchanged; no context/message injected.
|
// 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 adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||||
const ctx = await harness(path, adapter)
|
const ctx = await harness(path, adapter)
|
||||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
const res = events(agent).find(e => e.type === 'hook/result')
|
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 adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||||
const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 })
|
const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 })
|
||||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
const res = events(agent).find(e => e.type === 'hook/result')
|
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 path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||||
const ctx = await harness(path, adapter)
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
expect(adapter.requests).toHaveLength(2)
|
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 path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||||
const ctx = await harness(path, adapter)
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
// A second model request ran → the empty-reason block forced continuation.
|
// 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([]))
|
const ctx = await harness(path, new MockAdapter([]))
|
||||||
// Register a fake child agent under the id the event carries.
|
// Register a fake child agent under the id the event carries.
|
||||||
const injected: string[] = []
|
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<typeof ctx.agents.register>[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<typeof ctx.agents.register>[0]
|
||||||
ctx.agents.register(child)
|
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'))
|
await waitFor(() => injected.includes('child guidance'))
|
||||||
expect(injected).toContain('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 path = hooks(d, { SubagentStart: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||||
const ctx = await harness(path, new MockAdapter([]))
|
const ctx = await harness(path, new MockAdapter([]))
|
||||||
const warn = vi.fn(); ctx.logger.warn = warn as never
|
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<typeof ctx.agents.register>[0]
|
const child = { id: SessionId('child-y'), inject: () => { throw new Error('inject boom') }, session: { header: { id: 'child-y' } } } as unknown as Parameters<typeof ctx.agents.register>[0]
|
||||||
ctx.agents.register(child)
|
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')))
|
await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SubagentStart hook failed')))
|
||||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('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 adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||||
const ctx = await harness(path, adapter)
|
const ctx = await harness(path, adapter)
|
||||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'x' }] } }))
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
const result = events(agent).find(e => e.type === 'tool/result')
|
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 adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||||
const ctx = await harness(path, adapter)
|
const ctx = await harness(path, adapter)
|
||||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
const result = events(agent).find(e => e.type === 'tool/result')
|
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 s = sh(d, 'stop.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`)
|
||||||
const path = hooks(d, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] })
|
const path = hooks(d, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||||
const ctx = await harness(path, new MockAdapter([]))
|
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))
|
await waitFor(() => existsSync(marker))
|
||||||
expect(existsSync(marker)).toBe(true)
|
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 path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||||
const adapter = new MockAdapter([textResponse('no')])
|
const adapter = new MockAdapter([textResponse('no')])
|
||||||
const ctx = await harness(path, adapter)
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
const turnEnd = events(agent).findLast(e => e.type === 'turn/end')
|
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)
|
const ctx = await harness(path, adapter)
|
||||||
let ran = false
|
let ran = false
|
||||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
// ask (no reason) → degrades to deny with the registry's generic message.
|
// 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 adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||||
const ctx = await harness(path, adapter)
|
const ctx = await harness(path, adapter)
|
||||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
const res = events(agent).find(e => e.type === 'hook/result')
|
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).
|
// the protocol lib's reference default, not a config knob).
|
||||||
HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') })
|
HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') })
|
||||||
ctx.llm.registerAdapter(['mock'], adapter)
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
expect(existsSync(marker)).toBe(true)
|
expect(existsSync(marker)).toBe(true)
|
||||||
@@ -357,7 +358,7 @@ describe('hooks-claude coverage — schema-bypass apply + unspawnable hook', ()
|
|||||||
const ctx = await harness(path, adapter)
|
const ctx = await harness(path, adapter)
|
||||||
let ran = false
|
let ran = false
|
||||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
expect(ran).toBe(true)
|
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 adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||||
const ctx = await harness(path, adapter)
|
const ctx = await harness(path, adapter)
|
||||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
const result = events(agent).find(e => e.type === 'tool/result')
|
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)
|
const ctx = await harness(path, adapter)
|
||||||
let ran = false
|
let ran = false
|
||||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
const res = events(agent).find(e => e.type === 'hook/result')
|
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 adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||||
const ctx = await harness(path, adapter)
|
const ctx = await harness(path, adapter)
|
||||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
const result = events(agent).find(e => e.type === 'tool/result')
|
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)
|
const ctx = await harness(path, adapter)
|
||||||
let ran = false
|
let ran = false
|
||||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
expect(ran).toBe(true) // the mismatched deny was discarded → the tool ran
|
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
|
const ctx = await harness(path, adapter) // NB: no projectDir
|
||||||
// The factory create() path honors meta.cwd (the plain agentLoop.create() does not).
|
// The factory create() path honors meta.cwd (the plain agentLoop.create() does not).
|
||||||
const { SessionId } = await import('@deepseek-ai/dsh-session')
|
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' }])
|
handle.agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, handle.agent as ReactLoopAgent)
|
await waitForIdle(ctx, handle.agent as ReactLoopAgent)
|
||||||
expect(events(handle.agent as ReactLoopAgent).some(e => e.type === 'context/message'
|
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 adapter = new MockAdapter([textResponse('should not run')])
|
||||||
const ctx = await harness(path, adapter)
|
const ctx = await harness(path, adapter)
|
||||||
// A later listener that blocks every prompt (registered AFTER the bridge).
|
// 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' }))
|
ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' }))
|
||||||
const agent = ctx.agentLoop.create(AId('a1'), { model: 'mock' })
|
const agent = ctx.agentLoop.create(AId('a1'), { model: 'mock' })
|
||||||
agent.send([{ type: 'text', text: 'go' }])
|
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' }],
|
content: [{ type: 'text' as const, text: 'rewritten-prompt' }],
|
||||||
additionalContext: { content: [{ type: 'text' as const, text: 'from-downstream' }], source: { kind: 'plugin' as const, plugin: 'policy' } },
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
const req = JSON.stringify(adapter.requests[0]!.messages)
|
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)
|
const ctx = await harness(path, adapter)
|
||||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
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' }] }))
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
const result = events(agent).find(e => e.type === 'tool/result')
|
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)
|
const ctx = await harness(path, adapter)
|
||||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
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' }] }))
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
const result = events(agent).find(e => e.type === 'tool/result')
|
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
|
const bash = ctx.bash
|
||||||
bash.run = (() => Promise.reject(new Error('executor down')))
|
bash.run = (() => Promise.reject(new Error('executor down')))
|
||||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
const res = events(agent).find(e => e.type === 'hook/result')
|
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 path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(path, adapter)
|
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.
|
// Make inject throw, forcing the SessionStart .catch path.
|
||||||
const original = agent.inject.bind(agent)
|
const original = agent.inject.bind(agent)
|
||||||
let threw = false
|
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' }] } }))
|
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 { 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' }])
|
handle.agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, handle.agent as ReactLoopAgent)
|
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.
|
// Register a live child on its own session cwd; emit subagent/end with its id.
|
||||||
const { SessionId } = await import('@deepseek-ai/dsh-session')
|
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' })
|
ctx.emit('subagent/end', { provider: 'inproc', id: childHandle.agent.id, stopReason: 'completed' })
|
||||||
|
|
||||||
await waitFor(() => existsSync(marker))
|
await waitFor(() => existsSync(marker))
|
||||||
@@ -670,7 +671,7 @@ describe('hooks-claude coverage — systemMessage is warned, not surfaced', () =
|
|||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(path, adapter)
|
const ctx = await harness(path, adapter)
|
||||||
const warn = vi.fn(); ctx.logger.warn = warn as never
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage'))
|
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 path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(path, adapter)
|
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.
|
// Send immediately — do NOT wait for the session-start inject.
|
||||||
agent.send([{ type: 'text', text: 'go' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import { join } from 'node:path'
|
|||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import Loader from '@cordisjs/plugin-loader'
|
import Loader from '@cordisjs/plugin-loader'
|
||||||
import LlmService from '@deepseek-ai/dsh-llm'
|
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 SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
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 AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||||
import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
|
import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
|
||||||
@@ -82,7 +83,7 @@ describe('hooks-codex bridge', () => {
|
|||||||
const ctx = await harness(dir, adapter)
|
const ctx = await harness(dir, adapter)
|
||||||
let ran = false
|
let ran = false
|
||||||
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'no' }] } }))
|
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' }])
|
agent.send([{ type: 'text', text: 'run ls' }])
|
||||||
await waitForIdle(ctx, agent)
|
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.
|
// 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 adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer after goal')])
|
||||||
const ctx = await harness(dir, adapter)
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
@@ -124,7 +125,7 @@ describe('hooks-codex bridge', () => {
|
|||||||
|
|
||||||
const adapter = new MockAdapter([textResponse('fine')])
|
const adapter = new MockAdapter([textResponse('fine')])
|
||||||
const ctx = await harness(dir, adapter)
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
// Ran normally; the unknown event was dropped at parse.
|
// 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 dir = configDir() // no hooks.json written
|
||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(dir, adapter)
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
expect(adapter.requests).toHaveLength(1)
|
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' })
|
const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' })
|
||||||
await fiber.dispose()
|
await fiber.dispose()
|
||||||
ctx.llm.registerAdapter(['mock'], adapter)
|
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' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone
|
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([]))
|
ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
|
||||||
const warn = vi.fn()
|
const warn = vi.fn()
|
||||||
ctx.logger.warn = warn as never
|
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))
|
await waitFor(() => existsSync(marker))
|
||||||
const pid = Number(readFileSync(pidFile, 'utf8').trim())
|
const pid = Number(readFileSync(pidFile, 'utf8').trim())
|
||||||
await fiber.dispose()
|
await fiber.dispose()
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import { tmpdir } from 'node:os'
|
|||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import LlmService from '@deepseek-ai/dsh-llm'
|
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 SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
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 AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||||
import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
|
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') }] }] })
|
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 adapter = new MockAdapter([textResponse('no')])
|
||||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
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)
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||||
expect(adapter.requests).toHaveLength(0)
|
expect(adapter.requests).toHaveLength(0)
|
||||||
const te = events(agent).findLast(e => e.type === 'turn/end')
|
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') }] }] })
|
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 adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
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)
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||||
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('ctx-x')
|
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 adapter = new MockAdapter([textResponse('should not run')])
|
||||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||||
ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' }))
|
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)
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||||
expect(adapter.requests).toHaveLength(0)
|
expect(adapter.requests).toHaveLength(0)
|
||||||
expect(events(agent).some(e => e.type === 'user/message')).toBe(false)
|
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' }],
|
content: [{ type: 'text' as const, text: 'rewritten-prompt' }],
|
||||||
additionalContext: { content: [{ type: 'text' as const, text: 'from-downstream' }], source: { kind: 'plugin' as const, plugin: 'policy' } },
|
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)
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||||
const req = JSON.stringify(adapter.requests[0]!.messages)
|
const req = JSON.stringify(adapter.requests[0]!.messages)
|
||||||
expect(req).toContain('from-bridge')
|
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)
|
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.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' }] }))
|
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)
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||||
const result = events(agent).find(e => e.type === 'tool/result')
|
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)
|
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)
|
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.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' }] }))
|
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)
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||||
const result = events(agent).find(e => e.type === 'tool/result')
|
const result = events(agent).find(e => e.type === 'tool/result')
|
||||||
expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
|
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') }] }] })
|
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 adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
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'
|
await waitFor(() => events(agent).some(e => e.type === 'context/message'
|
||||||
&& e.data.content.some(b => b.type === 'text' && b.text.includes('start-ctx'))))
|
&& e.data.content.some(b => b.type === 'text' && b.text.includes('start-ctx'))))
|
||||||
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
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 adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')])
|
||||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
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' }] } }))
|
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)
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||||
const r = events(agent).find(e => e.type === 'tool/result')
|
const r = events(agent).find(e => e.type === 'tool/result')
|
||||||
expect(r?.type === 'tool/result' && r.data.isError).toBe(true)
|
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 adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')])
|
||||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
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' }] } }))
|
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)
|
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)
|
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)
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||||
let ran = false
|
let ran = false
|
||||||
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
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)
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||||
expect(ran).toBe(true) // clean-exit hook allows; commandOf returned ''
|
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 adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
|
||||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
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' }] } }))
|
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)
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||||
const res = events(agent).find(e => e.type === 'hook/result')
|
const res = events(agent).find(e => e.type === 'hook/result')
|
||||||
expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0)
|
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 adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
|
||||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
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' }] } }))
|
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)
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||||
const res = events(agent).find(e => e.type === 'hook/result')
|
const res = events(agent).find(e => e.type === 'hook/result')
|
||||||
expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true)
|
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 adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
|
||||||
const ctx = await harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: 40 })
|
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' }] } }))
|
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)
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||||
const res = events(agent).find(e => e.type === 'hook/result')
|
const res = events(agent).find(e => e.type === 'hook/result')
|
||||||
expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…')
|
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.
|
// Direct apply (schema bypass) → the `model ?? ''` fallback is exercised.
|
||||||
HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') })
|
HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') })
|
||||||
ctx.llm.registerAdapter(['mock'], adapter)
|
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: 'go' }]); await waitForIdle(ctx, agent)
|
||||||
expect(existsSync(marker)).toBe(true)
|
expect(existsSync(marker)).toBe(true)
|
||||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('async hook'))
|
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)
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||||
let ran = false
|
let ran = false
|
||||||
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
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)
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||||
expect(ran).toBe(true)
|
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`) }] }] })
|
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 adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
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
|
await waitFor(() => existsSync(marker)) // the clean no-output hook has finished
|
||||||
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||||
expect(events(agent).some(e => e.type === 'context/message')).toBe(false)
|
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 adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||||
const warn = vi.fn(); ctx.logger.warn = warn as never
|
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') })
|
agent.inject = (() => { throw new Error('inject boom') })
|
||||||
await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SessionStart hook failed')))
|
await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SessionStart hook failed')))
|
||||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('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)
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||||
let ran = false
|
let ran = false
|
||||||
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
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)
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||||
expect(ran).toBe(true)
|
expect(ran).toBe(true)
|
||||||
})
|
})
|
||||||
@@ -311,7 +312,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
|
|||||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||||
let ran = false
|
let ran = false
|
||||||
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
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)
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||||
expect(ran).toBe(true) // matcher didn't match → no hook ran → tool proceeded
|
expect(ran).toBe(true) // matcher didn't match → no hook ran → tool proceeded
|
||||||
expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false)
|
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)
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||||
let ran = false
|
let ran = false
|
||||||
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
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)
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||||
const res = events(agent).find(e => e.type === 'hook/result')
|
const res = events(agent).find(e => e.type === 'hook/result')
|
||||||
expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded
|
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 adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
|
||||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
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' }] } }))
|
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)
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||||
const r = events(agent).find(e => e.type === 'tool/result')
|
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)
|
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 adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
|
||||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
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' }] } }))
|
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)
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||||
const r = events(agent).find(e => e.type === 'tool/result')
|
const r = events(agent).find(e => e.type === 'tool/result')
|
||||||
expect(r?.type === 'tool/result' && r.data.isError).toBe(true)
|
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 adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 7 }), textResponse('done')])
|
||||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
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' }] } }))
|
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)
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||||
const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_input: { command: string } }
|
const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_input: { command: string } }
|
||||||
expect(payload.tool_input.command).toBe('')
|
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)
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||||
ctx.bash.run = (() => Promise.reject(new Error('executor down')))
|
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' }] } }))
|
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)
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||||
const res = events(agent).find(e => e.type === 'hook/result')
|
const res = events(agent).find(e => e.type === 'hook/result')
|
||||||
expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false)
|
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`) }] }] })
|
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 adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
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)
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||||
expect(adapter.requests).toHaveLength(2) // empty-reason block forced continuation
|
expect(adapter.requests).toHaveLength(2) // empty-reason block forced continuation
|
||||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('blocked by Stop hook')
|
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') }] }] })
|
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 adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
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)
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||||
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('extra guidance from a plain hook')
|
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`) }] }] })
|
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 adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
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
|
await waitFor(() => existsSync(marker)) // the exit-2 hook has finished
|
||||||
expect(events(agent).some(e => e.type === 'context/message'
|
expect(events(agent).some(e => e.type === 'context/message'
|
||||||
&& e.data.content.some(b => b.type === 'text' && b.text.includes('stale')))).toBe(false)
|
&& 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') }] }] })
|
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 adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
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)
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||||
expect(adapter.requests).toHaveLength(1) // exit 1 is non-blocking → the turn ran
|
expect(adapter.requests).toHaveLength(1) // exit 1 is non-blocking → the turn ran
|
||||||
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('stale')
|
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') }] }] })
|
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 adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
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'
|
await waitFor(() => events(agent).some(e => e.type === 'context/message'
|
||||||
&& e.data.content.some(b => b.type === 'text' && b.text.includes('session preamble'))))
|
&& e.data.content.some(b => b.type === 'text' && b.text.includes('session preamble'))))
|
||||||
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
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') }] }] })
|
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 adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
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)
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||||
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('unrelated')
|
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 adapter = new MockAdapter([toolCallResponse('c1', 'shell', { command: 'ls' }), textResponse('done')])
|
||||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
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' }] } }))
|
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)
|
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 } }
|
const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_name: string; tool_input: { command: string } }
|
||||||
expect(payload.tool_name).toBe('shell')
|
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)
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||||
let ran = false
|
let ran = false
|
||||||
ctx.tools.register(defineTool({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
|
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)
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||||
expect(ran).toBe(false) // the matcher fired → the hook denied the tool
|
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)
|
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 adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||||
const warn = vi.fn(); ctx.logger.warn = warn as never
|
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)
|
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage'))
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage'))
|
||||||
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up')
|
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.llm.registerAdapter(['mock'], adapter)
|
||||||
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
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 { 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' }])
|
handle.agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, handle.agent as ReactLoopAgent)
|
await waitForIdle(ctx, handle.agent as ReactLoopAgent)
|
||||||
expect(existsSync(marker)).toBe(true)
|
expect(existsSync(marker)).toBe(true)
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ import {
|
|||||||
type SessionNotification,
|
type SessionNotification,
|
||||||
type StopReason,
|
type StopReason,
|
||||||
} from '@agentclientprotocol/sdk'
|
} from '@agentclientprotocol/sdk'
|
||||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
|
||||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
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 type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
|
||||||
import { buildChildEnv, disposeChildProcess, spawnFailure } from '@deepseek-ai/dsh-subagent-subprocess'
|
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.
|
* @returns the ready run handle for the child subprocess.
|
||||||
*/
|
*/
|
||||||
export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Promise<SubagentRun> {
|
export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Promise<SubagentRun> {
|
||||||
const id = AgentId(randomUUID())
|
const id = SessionId(randomUUID())
|
||||||
|
|
||||||
if (request.signal.aborted) throw new Error('subagent request was aborted before the ACP child started')
|
if (request.signal.aborted) throw new Error('subagent request was aborted before the ACP child started')
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import LlmService from '@deepseek-ai/dsh-llm'
|
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 SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
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 AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||||
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
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(Spawn, { providerName: 'spawn' })
|
||||||
await ctx.plugin(fork, { providerName: 'fork' })
|
await ctx.plugin(fork, { providerName: 'fork' })
|
||||||
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
|
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 }
|
return { ctx, parent }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import Loader from '@cordisjs/plugin-loader'
|
import Loader from '@cordisjs/plugin-loader'
|
||||||
import LlmService from '@deepseek-ai/dsh-llm'
|
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 SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
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 AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||||
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
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(SubagentService)
|
||||||
await ctx.plugin(fork, { providerName: 'fork' })
|
await ctx.plugin(fork, { providerName: 'fork' })
|
||||||
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
|
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 }
|
return { ctx, parent }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
import type { Context } from 'cordis'
|
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 { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||||
import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent'
|
import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent'
|
||||||
@@ -104,7 +104,7 @@ export async function startInProcessRun(
|
|||||||
throw new SubagentDepthError(childDepth, request.maxDepth)
|
throw new SubagentDepthError(childDepth, request.maxDepth)
|
||||||
}
|
}
|
||||||
|
|
||||||
const childId = AgentId(randomUUID())
|
const childId = SessionId(randomUUID())
|
||||||
const seedLength = options.seed?.length ?? 0
|
const seedLength = options.seed?.length ?? 0
|
||||||
const parentHeader = parent.session.header
|
const parentHeader = parent.session.header
|
||||||
const parentModel = parent.options.model
|
const parentModel = parent.options.model
|
||||||
@@ -127,8 +127,7 @@ export async function startInProcessRun(
|
|||||||
|
|
||||||
const flags = { cancelled: false }
|
const flags = { cancelled: false }
|
||||||
const handle = await parent.ctx.agents.create({
|
const handle = await parent.ctx.agents.create({
|
||||||
agentId: childId,
|
sessionId: childId,
|
||||||
sessionId: SessionId(randomUUID()),
|
|
||||||
meta: {
|
meta: {
|
||||||
...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {},
|
...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {},
|
||||||
parentSession: parentHeader.id,
|
parentSession: parentHeader.id,
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import LlmService, { CallId, type ContentBlock, type GenerateOptions } from '@deepseek-ai/dsh-llm'
|
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 SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
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 type { ContinuationDecision } from '@deepseek-ai/dsh-agent'
|
||||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||||
@@ -68,7 +69,7 @@ async function setup(script: Script, options: SetupOptions = {}) {
|
|||||||
start: (request: SubagentStartRequest) => startInProcessRun(request, {}),
|
start: (request: SubagentStartRequest) => startInProcessRun(request, {}),
|
||||||
})
|
})
|
||||||
ctx.llm.registerAdapter(['mock'], adapter)
|
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 }
|
return { ctx, parent, adapter, disposeProvider }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,7 +334,7 @@ describe('in-process structured output', () => {
|
|||||||
await expect(ctx.subagents.start('spawn', structuredRequest(parent, {
|
await expect(ctx.subagents.start('spawn', structuredRequest(parent, {
|
||||||
outputSchema: { type: 'object', oneOf: [] } as unknown as StructuredOutputSchema,
|
outputSchema: { type: 'object', oneOf: [] } as unknown as StructuredOutputSchema,
|
||||||
}))).rejects.toThrow(/unsupported output schema/)
|
}))).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 () => {
|
it('a schema carrying non-JSON values fails as OutputSchemaError at the validation boundary', async () => {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import LlmService from '@deepseek-ai/dsh-llm'
|
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 SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
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 AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||||
@@ -24,7 +25,7 @@ async function setup(script: Script) {
|
|||||||
await ctx.plugin(AgentLoop, { agents: [] })
|
await ctx.plugin(AgentLoop, { agents: [] })
|
||||||
await ctx.plugin(SubagentService)
|
await ctx.plugin(SubagentService)
|
||||||
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
|
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 }
|
return { ctx, parent }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { tmpdir } from 'node:os'
|
|||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
import type { Context } from 'cordis'
|
import type { Context } from 'cordis'
|
||||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
|
||||||
import { spawnHarness, waitForIdle } from './harness.ts'
|
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
|
* 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 () => {
|
it('a parent delegates to a child that writes a file on disk', async () => {
|
||||||
workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-spawn-e2e-'))
|
workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-spawn-e2e-'))
|
||||||
ctx = await spawnHarness(workdir)
|
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:
|
parent.send([{ type: 'text', text:
|
||||||
'Use the subagent tool to delegate this exact task: "Use the bash tool to write the text '
|
'Use the subagent tool to delegate this exact task: "Use the bash tool to write the text '
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import LlmService from '@deepseek-ai/dsh-llm'
|
|||||||
import SessionStore from '@deepseek-ai/dsh-session'
|
import SessionStore from '@deepseek-ai/dsh-session'
|
||||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
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 { SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||||
@@ -36,7 +37,7 @@ async function setup(script: Script) {
|
|||||||
await ctx.plugin(SubagentService)
|
await ctx.plugin(SubagentService)
|
||||||
await ctx.plugin(spawn, { providerName: 'spawn' })
|
await ctx.plugin(spawn, { providerName: 'spawn' })
|
||||||
ctx.llm.registerAdapter(['mock'], adapter)
|
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 }
|
return { ctx, parent, adapter }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +238,6 @@ describe('dsh-subagent-spawn', () => {
|
|||||||
const { ctx } = await setup([textResponse('x')])
|
const { ctx } = await setup([textResponse('x')])
|
||||||
// A parent WITH a cwd (config agents have none, so create one explicitly).
|
// A parent WITH a cwd (config agents have none, so create one explicitly).
|
||||||
const parentHandle = await ctx.agents.create({
|
const parentHandle = await ctx.agents.create({
|
||||||
agentId: AgentId('cwd-parent'),
|
|
||||||
sessionId: SessionId('cwd-parent-session'),
|
sessionId: SessionId('cwd-parent-session'),
|
||||||
meta: { cwd: '/tmp/parent-workspace' },
|
meta: { cwd: '/tmp/parent-workspace' },
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
@@ -254,7 +254,6 @@ describe('dsh-subagent-spawn', () => {
|
|||||||
const { ctx } = await setup([textResponse('explicit model child')])
|
const { ctx } = await setup([textResponse('explicit model child')])
|
||||||
// A parent with NO model (its own turns would need one supplied per-request).
|
// A parent with NO model (its own turns would need one supplied per-request).
|
||||||
const parentHandle = await ctx.agents.create({
|
const parentHandle = await ctx.agents.create({
|
||||||
agentId: AgentId('modelless-parent'),
|
|
||||||
sessionId: SessionId('modelless-parent-session'),
|
sessionId: SessionId('modelless-parent-session'),
|
||||||
agentOptions: {},
|
agentOptions: {},
|
||||||
})
|
})
|
||||||
@@ -318,7 +317,7 @@ describe('dsh-subagent-spawn', () => {
|
|||||||
await ctx.plugin(SubagentService)
|
await ctx.plugin(SubagentService)
|
||||||
const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
|
const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
|
||||||
ctx.llm.registerAdapter(['mock'], adapter)
|
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 controller = new AbortController()
|
||||||
const run = await start(ctx, 'spawn', {
|
const run = await start(ctx, 'spawn', {
|
||||||
prompt: [{ type: 'text', text: 'q' }],
|
prompt: [{ type: 'text', text: 'q' }],
|
||||||
@@ -349,7 +348,7 @@ describe('dsh-subagent-spawn', () => {
|
|||||||
await ctx.plugin(AgentLoop, { agents: [] })
|
await ctx.plugin(AgentLoop, { agents: [] })
|
||||||
await ctx.plugin(SubagentService)
|
await ctx.plugin(SubagentService)
|
||||||
const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
|
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 parentEffects = parent.ctx.fiber.getEffects().length
|
||||||
const published: string[] = []
|
const published: string[] = []
|
||||||
ctx.on('session/created', () => void published.push('session/created'))
|
ctx.on('session/created', () => void published.push('session/created'))
|
||||||
@@ -442,7 +441,6 @@ describe('dsh-subagent-spawn', () => {
|
|||||||
const { ctx } = await setup([])
|
const { ctx } = await setup([])
|
||||||
// A handle-owned parent we can dispose (config agents dispose with the loop fiber).
|
// A handle-owned parent we can dispose (config agents dispose with the loop fiber).
|
||||||
const parentHandle = await ctx.agents.create({
|
const parentHandle = await ctx.agents.create({
|
||||||
agentId: AgentId('doomed-parent'),
|
|
||||||
sessionId: SessionId('doomed-s'),
|
sessionId: SessionId('doomed-s'),
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
})
|
})
|
||||||
@@ -465,7 +463,6 @@ describe('dsh-subagent-spawn', () => {
|
|||||||
it('parent disposal during the child setup transaction prevents every publication notification', async () => {
|
it('parent disposal during the child setup transaction prevents every publication notification', async () => {
|
||||||
const { ctx } = await setup([])
|
const { ctx } = await setup([])
|
||||||
const parentHandle = await ctx.agents.create({
|
const parentHandle = await ctx.agents.create({
|
||||||
agentId: AgentId('setup-race-parent'),
|
|
||||||
sessionId: SessionId('setup-race-parent-session'),
|
sessionId: SessionId('setup-race-parent-session'),
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import type { Scoped } from '@deepseek-ai/dsh-scope'
|
|||||||
import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools'
|
import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools'
|
||||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||||
import type { ContentBlock } 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 {
|
import type {
|
||||||
SubagentCapabilities,
|
SubagentCapabilities,
|
||||||
SubagentProvider,
|
SubagentProvider,
|
||||||
@@ -96,7 +97,7 @@ export interface SubagentRunInfo {
|
|||||||
/** The provider that established the run. */
|
/** The provider that established the run. */
|
||||||
readonly provider: string
|
readonly provider: string
|
||||||
/** The child agent's id. */
|
/** The child agent's id. */
|
||||||
readonly id: AgentId
|
readonly id: SessionId
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Observe-only outcome detail for a settled subagent run. */
|
/** Observe-only outcome detail for a settled subagent run. */
|
||||||
@@ -104,7 +105,7 @@ export interface SubagentRunEndInfo {
|
|||||||
/** The provider that ran it. */
|
/** The provider that ran it. */
|
||||||
readonly provider: string
|
readonly provider: string
|
||||||
/** The child agent's id. */
|
/** The child agent's id. */
|
||||||
readonly id: AgentId
|
readonly id: SessionId
|
||||||
/** The terminal stop reason. */
|
/** The terminal stop reason. */
|
||||||
readonly stopReason: SubagentResult['stopReason']
|
readonly stopReason: SubagentResult['stopReason']
|
||||||
/** The child's final assistant output, absent on infrastructure rejection. */
|
/** The child's final assistant output, absent on infrastructure rejection. */
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
* @module @deepseek-ai/dsh-subagent/types
|
* @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 { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||||
|
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import type { StructuredOutputSchema, ToolRestriction } from '@deepseek-ai/dsh-tools'
|
import type { StructuredOutputSchema, ToolRestriction } from '@deepseek-ai/dsh-tools'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -147,7 +148,7 @@ export interface SubagentResult {
|
|||||||
*/
|
*/
|
||||||
export interface SubagentRun {
|
export interface SubagentRun {
|
||||||
/** The child agent's id (local in-process runs are already published in `ctx.agents`; remote transports need not publish locally). */
|
/** 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
|
* Resolves with the child's terminal {@link SubagentResult} when the run
|
||||||
* settles. Does NOT reject on a child-level failure — a model/transport
|
* settles. Does NOT reject on a child-level failure — a model/transport
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { Context } from 'cordis'
|
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 { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||||
import { carrierKeyOf } from '@deepseek-ai/dsh-scope'
|
import { carrierKeyOf } from '@deepseek-ai/dsh-scope'
|
||||||
import SubagentService, {
|
import SubagentService, {
|
||||||
@@ -12,9 +13,10 @@ import SubagentService, {
|
|||||||
type SubagentRun,
|
type SubagentRun,
|
||||||
type SubagentStartRequest,
|
type SubagentStartRequest,
|
||||||
} from '@deepseek-ai/dsh-subagent'
|
} from '@deepseek-ai/dsh-subagent'
|
||||||
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||||
|
|
||||||
function fakeParent(id = 'parent-1'): Agent {
|
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 }
|
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<SubagentRun> {
|
async start(request: SubagentStartRequest): Promise<SubagentRun> {
|
||||||
this.startCount += 1
|
this.startCount += 1
|
||||||
return {
|
return {
|
||||||
id: AgentId(`child:${this.name}:${request.parent.id}`),
|
id: SessionId(`child:${this.name}:${request.parent.id}`),
|
||||||
result: Promise.resolve(this.outcome),
|
result: Promise.resolve(this.outcome),
|
||||||
async dispose() {},
|
async dispose() {},
|
||||||
}
|
}
|
||||||
@@ -141,7 +143,7 @@ describe('SubagentService', () => {
|
|||||||
const starting = subagents.start('deferred', baseRequest({ parent }))
|
const starting = subagents.start('deferred', baseRequest({ parent }))
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
expect(events).toEqual([])
|
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
|
const run = await starting
|
||||||
expect(events).toEqual(['start'])
|
expect(events).toEqual(['start'])
|
||||||
result.resolve({ output: [{ type: 'text', text: 'answer' }], stopReason: 'completed' })
|
result.resolve({ output: [{ type: 'text', text: 'answer' }], stopReason: 'completed' })
|
||||||
@@ -190,7 +192,7 @@ describe('SubagentService', () => {
|
|||||||
capabilities: NO_CAPS,
|
capabilities: NO_CAPS,
|
||||||
inheritsParentContext: false,
|
inheritsParentContext: false,
|
||||||
async start() {
|
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())
|
const failedRun = await subagents.start('infra', baseRequest())
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import Loader from '@cordisjs/plugin-loader'
|
|||||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
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 SubagentService from '@deepseek-ai/dsh-subagent'
|
||||||
import * as mock from '@deepseek-ai/dsh-subagent-mock'
|
import * as mock from '@deepseek-ai/dsh-subagent-mock'
|
||||||
import * as tool from '../src/index.ts'
|
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
|
* 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`. */
|
/** A minimal parent Agent — the tool reads `agent.id` for `parent`. */
|
||||||
function fakeAgent(id = 'parent-1'): Agent {
|
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<mock.Config> = {}) {
|
async function setup(toolConfig: tool.Config, mockConfig: Partial<mock.Config> = {}) {
|
||||||
@@ -114,7 +116,7 @@ describe('dsh-tool-subagent', () => {
|
|||||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||||
inheritsParentContext: false,
|
inheritsParentContext: false,
|
||||||
start: async () => ({
|
start: async () => ({
|
||||||
id: AgentId('weird-child'),
|
id: SessionId('weird-child'),
|
||||||
result: Promise.resolve({ output: [{ type: 'text', text: 'partial' }], stopReason: 'frobnicated' as never }),
|
result: Promise.resolve({ output: [{ type: 'text', text: 'partial' }], stopReason: 'frobnicated' as never }),
|
||||||
dispose: async () => {},
|
dispose: async () => {},
|
||||||
}),
|
}),
|
||||||
@@ -141,7 +143,7 @@ describe('dsh-tool-subagent', () => {
|
|||||||
start: async (request) => {
|
start: async (request) => {
|
||||||
seen = request
|
seen = request
|
||||||
return {
|
return {
|
||||||
id: AgentId('capture-child'),
|
id: SessionId('capture-child'),
|
||||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||||
dispose: async () => {},
|
dispose: async () => {},
|
||||||
}
|
}
|
||||||
@@ -170,7 +172,7 @@ describe('dsh-tool-subagent', () => {
|
|||||||
start: async (request) => {
|
start: async (request) => {
|
||||||
seen = request
|
seen = request
|
||||||
return {
|
return {
|
||||||
id: AgentId('bare-child'),
|
id: SessionId('bare-child'),
|
||||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||||
dispose: async () => {},
|
dispose: async () => {},
|
||||||
}
|
}
|
||||||
@@ -297,7 +299,7 @@ describe('dsh-tool-subagent', () => {
|
|||||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||||
inheritsParentContext: false,
|
inheritsParentContext: false,
|
||||||
start: async () => ({
|
start: async () => ({
|
||||||
id: AgentId('spy-child'),
|
id: SessionId('spy-child'),
|
||||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||||
dispose: async () => void disposed(),
|
dispose: async () => void disposed(),
|
||||||
}),
|
}),
|
||||||
@@ -319,7 +321,7 @@ describe('dsh-tool-subagent', () => {
|
|||||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||||
inheritsParentContext: false,
|
inheritsParentContext: false,
|
||||||
start: async () => ({
|
start: async () => ({
|
||||||
id: AgentId('spy-child'),
|
id: SessionId('spy-child'),
|
||||||
result: Promise.resolve({ output: [], stopReason: 'error' as const }),
|
result: Promise.resolve({ output: [], stopReason: 'error' as const }),
|
||||||
dispose: async () => void disposed(),
|
dispose: async () => void disposed(),
|
||||||
}),
|
}),
|
||||||
@@ -350,7 +352,7 @@ describe('dsh-tool-subagent', () => {
|
|||||||
resolveResult({ output: [], stopReason: 'aborted' })
|
resolveResult({ output: [], stopReason: 'aborted' })
|
||||||
}, { once: true })
|
}, { once: true })
|
||||||
return {
|
return {
|
||||||
id: AgentId('spy-child'),
|
id: SessionId('spy-child'),
|
||||||
result,
|
result,
|
||||||
dispose: async () => {},
|
dispose: async () => {},
|
||||||
}
|
}
|
||||||
@@ -442,7 +444,7 @@ describe('dsh-tool-subagent', () => {
|
|||||||
start: async (request) => {
|
start: async (request) => {
|
||||||
seen = request
|
seen = request
|
||||||
return {
|
return {
|
||||||
id: AgentId('capture2-child'),
|
id: SessionId('capture2-child'),
|
||||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||||
dispose: async () => {},
|
dispose: async () => {},
|
||||||
}
|
}
|
||||||
@@ -499,7 +501,7 @@ describe('dsh-tool-subagent', () => {
|
|||||||
start: async (request) => {
|
start: async (request) => {
|
||||||
seen = request
|
seen = request
|
||||||
return {
|
return {
|
||||||
id: AgentId('capture3-child'),
|
id: SessionId('capture3-child'),
|
||||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||||
dispose: async () => {},
|
dispose: async () => {},
|
||||||
}
|
}
|
||||||
@@ -528,7 +530,7 @@ describe('dsh-tool-subagent', () => {
|
|||||||
start: async (request) => {
|
start: async (request) => {
|
||||||
seen = request
|
seen = request
|
||||||
return {
|
return {
|
||||||
id: AgentId('capture4-child'),
|
id: SessionId('capture4-child'),
|
||||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||||
dispose: async () => {},
|
dispose: async () => {},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
|
|
||||||
import type { Context } from 'cordis'
|
import type { Context } from 'cordis'
|
||||||
import z from 'schemastery'
|
import z from 'schemastery'
|
||||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
|
||||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||||
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import type {
|
import type {
|
||||||
SubagentCapabilities,
|
SubagentCapabilities,
|
||||||
SubagentProvider,
|
SubagentProvider,
|
||||||
@@ -65,7 +65,7 @@ class MockSubagentProvider implements SubagentProvider {
|
|||||||
|
|
||||||
// A deterministic child id derived from the parent — no clock/random (both
|
// A deterministic child id derived from the parent — no clock/random (both
|
||||||
// banned in deterministic paths here, and unnecessary for a scripted run).
|
// 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 => ({
|
const resultFor = (): SubagentResult => ({
|
||||||
output,
|
output,
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import Loader from '@cordisjs/plugin-loader'
|
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 SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||||
import * as mock from '../src/index.ts'
|
import * as mock from '../src/index.ts'
|
||||||
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||||
|
|
||||||
/** A minimal parent — the mock provider only reads `parent.id`. */
|
/** A minimal parent — the mock provider only reads `parent.id`. */
|
||||||
function fakeParent(id = 'parent-1'): Agent {
|
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> = {}): SubagentStartRequest {
|
function baseRequest(over: Partial<SubagentStartRequest> = {}): SubagentStartRequest {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import LlmService from '@deepseek-ai/dsh-llm'
|
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 type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
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 AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||||
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
|
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
|
||||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
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.'),
|
textResponse('Plan recorded.'),
|
||||||
])
|
])
|
||||||
const ctx = await harness(adapter)
|
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' }])
|
agent.send([{ type: 'text', text: 'plan a two-step task' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
@@ -92,7 +93,7 @@ describe('todo_write tool through the agent loop', () => {
|
|||||||
textResponse('Done planning.'),
|
textResponse('Done planning.'),
|
||||||
])
|
])
|
||||||
const ctx = await harness(adapter)
|
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' }])
|
agent.send([{ type: 'text', text: 'plan then update' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
|||||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import type { TodoItem } 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'
|
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`. */
|
/** A parent Agent backed by a real Session — the tool reads `agent.session`. */
|
||||||
function agentWithSession(id = 'parent-1'): Agent & { session: Session } {
|
function agentWithSession(id = 'parent-1'): Agent & { session: Session } {
|
||||||
const session = new Session(SessionId(id))
|
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<Context> {
|
async function setup(): Promise<Context> {
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ import {
|
|||||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||||
import { assertNever, CallId } from '@deepseek-ai/dsh-llm'
|
import { assertNever, CallId } from '@deepseek-ai/dsh-llm'
|
||||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
|
||||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-bash'
|
import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-bash'
|
||||||
import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
|
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)
|
validateMcpServers(params)
|
||||||
const sessionId = SessionId(randomUUID())
|
const sessionId = SessionId(randomUUID())
|
||||||
const handle = await agents.create({
|
const handle = await agents.create({
|
||||||
agentId: AgentId(sessionId),
|
|
||||||
sessionId,
|
sessionId,
|
||||||
meta: { cwd: params.cwd },
|
meta: { cwd: params.cwd },
|
||||||
agentOptions: agentOptions(config),
|
agentOptions: agentOptions(config),
|
||||||
@@ -757,7 +755,6 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const handle = await agents.resume({
|
const handle = await agents.resume({
|
||||||
agentId: AgentId(sessionId),
|
|
||||||
resumeSessionId: sessionId,
|
resumeSessionId: sessionId,
|
||||||
agentOptions: agentOptions(config),
|
agentOptions: agentOptions(config),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import { tmpdir } from 'node:os'
|
|||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
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 ApprovalService, { type ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
|
||||||
import { makeBridgeHarness, type BridgeHarness } from './harness.ts'
|
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
|
* 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 }> {
|
): Promise<{ agent: Agent; request: ApprovalRequest }> {
|
||||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
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')
|
if (agent === undefined) throw new Error('newSession created no agent')
|
||||||
// In production an ask always fires mid-turn (tool execution); open one so
|
// In production an ask always fires mid-turn (tool execution); open one so
|
||||||
// request()'s turn-enclosure precondition holds for the direct drive below.
|
// request()'s turn-enclosure precondition holds for the direct drive below.
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
|||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
|
||||||
import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness } from './harness.ts'
|
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
|
* End-to-end bridge specs over an in-memory transport: a real
|
||||||
@@ -98,7 +98,7 @@ describe('acp bridge', () => {
|
|||||||
required: [],
|
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 toolResultBlock = toolResult?.type === 'tool/result' ? toolResult.data.content[0] : undefined
|
||||||
const toolResultText = toolResultBlock?.type === 'text' ? toolResultBlock.text : undefined
|
const toolResultText = toolResultBlock?.type === 'text' ? toolResultBlock.text : undefined
|
||||||
expect(toolResultText).toBe('{"answers":[{"id":"language","selected":["Python"]}]}')
|
expect(toolResultText).toBe('{"answers":[{"id":"language","selected":["Python"]}]}')
|
||||||
@@ -127,7 +127,7 @@ describe('acp bridge', () => {
|
|||||||
required: ['custom'],
|
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')
|
expect(JSON.stringify(toolResult)).toContain('apollo')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ describe('acp bridge', () => {
|
|||||||
harness.onElicitation = () => ({ action: 'accept', content: { custom: 'Use Zig' } })
|
harness.onElicitation = () => ({ action: 'accept', content: { custom: 'Use Zig' } })
|
||||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
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({
|
const result = await harness.ctx.userInteraction.ask({
|
||||||
agent,
|
agent,
|
||||||
@@ -167,7 +167,7 @@ describe('acp bridge', () => {
|
|||||||
harness.onElicitation = () => ({ action: 'accept', content: { choice: 'TypeScript', custom: 'Use Zig' } })
|
harness.onElicitation = () => ({ action: 'accept', content: { choice: 'TypeScript', custom: 'Use Zig' } })
|
||||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
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({
|
await expect(harness.ctx.userInteraction.ask({
|
||||||
agent,
|
agent,
|
||||||
@@ -184,7 +184,7 @@ describe('acp bridge', () => {
|
|||||||
harness.onElicitation = () => ({ action: 'accept', content: { choice: ['Tests', 'Docs'] } })
|
harness.onElicitation = () => ({ action: 'accept', content: { choice: ['Tests', 'Docs'] } })
|
||||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
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({
|
await expect(harness.ctx.userInteraction.ask({
|
||||||
agent,
|
agent,
|
||||||
@@ -201,7 +201,7 @@ describe('acp bridge', () => {
|
|||||||
harness = await makeBridgeHarness({ storageDir, withAskUser: true })
|
harness = await makeBridgeHarness({ storageDir, withAskUser: true })
|
||||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
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?' }] }))
|
await expect(harness.ctx.userInteraction.ask({ questions: [{ id: 'x', question: 'No agent?' }] }))
|
||||||
.rejects.toMatchObject({ name: 'UserInteractionError', code: 'NO_AGENT' })
|
.rejects.toMatchObject({ name: 'UserInteractionError', code: 'NO_AGENT' })
|
||||||
@@ -225,7 +225,7 @@ describe('acp bridge', () => {
|
|||||||
harness = await makeBridgeHarness({ storageDir, withAskUser: true })
|
harness = await makeBridgeHarness({ storageDir, withAskUser: true })
|
||||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
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()
|
const alreadyAborted = new AbortController()
|
||||||
alreadyAborted.abort()
|
alreadyAborted.abort()
|
||||||
@@ -265,8 +265,8 @@ describe('acp bridge', () => {
|
|||||||
expect(b.sessionId).toBeTruthy()
|
expect(b.sessionId).toBeTruthy()
|
||||||
expect(a.sessionId).not.toBe(b.sessionId)
|
expect(a.sessionId).not.toBe(b.sessionId)
|
||||||
// Both agents are live and independently registered.
|
// Both agents are live and independently registered.
|
||||||
expect(harness.ctx.agents.get(AgentId(a.sessionId))).toBeDefined()
|
expect(harness.ctx.agents.get(SessionId(a.sessionId))).toBeDefined()
|
||||||
expect(harness.ctx.agents.get(AgentId(b.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 () => {
|
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: [] })
|
const res = await harness.client.newSession({ cwd: '/tmp', mcpServers: [] })
|
||||||
expect(res.sessionId).toBeTruthy()
|
expect(res.sessionId).toBeTruthy()
|
||||||
// The session header records that cwd, so its bash tools run there.
|
// 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 () => {
|
it('rejects non-empty additionalDirectories', async () => {
|
||||||
@@ -321,7 +321,7 @@ describe('acp bridge', () => {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
expect(result.stopReason).toBe('end_turn')
|
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')
|
expect(JSON.stringify(user)).toContain('resource_link')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { tmpdir } from 'node:os'
|
|||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
|
||||||
import { makeBridgeHarness, textResponse } from './harness.ts'
|
import { makeBridgeHarness, textResponse } from './harness.ts'
|
||||||
|
|
||||||
describe('acp bridge — disposal & HMR safety', () => {
|
describe('acp bridge — disposal & HMR safety', () => {
|
||||||
@@ -17,7 +16,7 @@ describe('acp bridge — disposal & HMR safety', () => {
|
|||||||
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
|
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
|
||||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
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.
|
// Start a prompt that hangs in the model stream.
|
||||||
const promptDone = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
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: [] })
|
const harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
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
|
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()
|
await harness.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -92,7 +91,7 @@ describe('acp bridge — disposal & HMR safety', () => {
|
|||||||
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
|
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
|
||||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
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
|
// Start a prompt that hangs in the model stream. The prompt RPC will never
|
||||||
// return (its transport is severed), so do not await it.
|
// return (its transport is severed), so do not await it.
|
||||||
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
|
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
|
// and its session removed from the store, not merely idled (the old
|
||||||
// behavior). The services live on the root ctx, so they survive this.
|
// behavior). The services live on the root ctx, so they survive this.
|
||||||
await harness.acpFiber.dispose()
|
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()
|
expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined()
|
||||||
await harness.dispose()
|
await harness.dispose()
|
||||||
})
|
})
|
||||||
@@ -128,7 +127,7 @@ describe('acp bridge — disposal & HMR safety', () => {
|
|||||||
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
|
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
|
||||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
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(() => {})
|
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
|
||||||
await new Promise(r => setTimeout(r, 30))
|
await new Promise(r => setTimeout(r, 30))
|
||||||
expect(agent.status).toBe('running')
|
expect(agent.status).toBe('running')
|
||||||
@@ -145,7 +144,7 @@ describe('acp bridge — disposal & HMR safety', () => {
|
|||||||
const harness = await makeBridgeHarness({ storageDir, script: [] })
|
const harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
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()
|
await harness.ctx.fiber.dispose()
|
||||||
const before = harness.updates.length
|
const before = harness.updates.length
|
||||||
@@ -169,12 +168,12 @@ describe('acp bridge — disposal & HMR safety', () => {
|
|||||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
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)
|
expect(liveEvents).toBeGreaterThan(0)
|
||||||
|
|
||||||
// Tear down JUST the bridge (the AgentHandle dispose runs to quiescence).
|
// Tear down JUST the bridge (the AgentHandle dispose runs to quiescence).
|
||||||
await harness.acpFiber.dispose()
|
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
|
// Re-load the session from disk: every live event (incl. the closing
|
||||||
// turn/end) was flushed before the session was detached.
|
// 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'] })
|
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
|
||||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
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(() => {})
|
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
|
||||||
await new Promise(r => setTimeout(r, 30))
|
await new Promise(r => setTimeout(r, 30))
|
||||||
expect(agent.status).toBe('running')
|
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
|
// Dispose JUST the bridge: a fiber unload that must STILL honor the ordered
|
||||||
// teardown (the composite effect runs its disposer chain as a unit).
|
// teardown (the composite effect runs its disposer chain as a unit).
|
||||||
await harness.acpFiber.dispose()
|
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
|
// The loop's own `turn/end {disposed}` is on disk (re-load: the world, not
|
||||||
// self-report) — NOT a crash-recovery `interrupted` substitute.
|
// 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.
|
// queryable, with its session still in the store.
|
||||||
const harness = await makeBridgeHarness({ storageDir, script: [] })
|
const harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||||
const handleA = await harness.ctx.agents.create({
|
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({
|
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(SessionId('sib-a'))).toBe(handleA.agent)
|
||||||
expect(harness.ctx.agents.get(AgentId('sib-b'))).toBe(handleB.agent)
|
expect(harness.ctx.agents.get(SessionId('sib-b'))).toBe(handleB.agent)
|
||||||
|
|
||||||
await handleA.dispose()
|
await handleA.dispose()
|
||||||
// A is gone — unregistered AND its session removed from the store.
|
// 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(harness.ctx.sessions.get(SessionId('sib-a'))).toBeUndefined()
|
||||||
expect(handleA.agent.status).toBe('disposed')
|
expect(handleA.agent.status).toBe('disposed')
|
||||||
// B is wholly unaffected.
|
// 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(harness.ctx.sessions.get(SessionId('sib-b'))).toBeDefined()
|
||||||
expect(handleB.agent.status).not.toBe('disposed')
|
expect(handleB.agent.status).not.toBe('disposed')
|
||||||
await harness.dispose()
|
await harness.dispose()
|
||||||
@@ -262,7 +261,7 @@ describe('acp bridge — disposal & HMR safety', () => {
|
|||||||
const harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] })
|
const harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] })
|
||||||
harness.ctx.on('agent/disposed', () => { throw new Error('boom disposed listener') })
|
harness.ctx.on('agent/disposed', () => { throw new Error('boom disposed listener') })
|
||||||
const handle = await harness.ctx.agents.create({
|
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' }])
|
handle.agent.send([{ type: 'text', text: 'go' }])
|
||||||
await handle.agent.whenIdle()
|
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.
|
// Dispose: the throwing listener must NOT break the chain before detach.
|
||||||
await handle.dispose()
|
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
|
expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeUndefined() // detach still ran
|
||||||
await harness.dispose()
|
await harness.dispose()
|
||||||
})
|
})
|
||||||
@@ -283,7 +282,7 @@ describe('acp bridge — disposal & HMR safety', () => {
|
|||||||
// observe the same quiescence boundary.
|
// observe the same quiescence boundary.
|
||||||
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
|
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
|
||||||
const handle = await harness.ctx.agents.create({
|
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
|
// 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
|
// 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.
|
// Release the flush; both resolve together and the session is gone.
|
||||||
releaseFlush()
|
releaseFlush()
|
||||||
await Promise.all([first, second])
|
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()
|
expect(harness.ctx.sessions.get(SessionId('conc-a'))).toBeUndefined()
|
||||||
await harness.dispose()
|
await harness.dispose()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
|||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
|
||||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
|
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: [] })
|
await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||||
const before = harness.updates.length
|
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' }])
|
foreign.send([{ type: 'text', text: 'hi' }])
|
||||||
await foreign.whenIdle()
|
await foreign.whenIdle()
|
||||||
await new Promise(r => setTimeout(r, 10))
|
await new Promise(r => setTimeout(r, 10))
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { tmpdir } from 'node:os'
|
|||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||||
import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
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'
|
import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts'
|
||||||
|
|
||||||
/** Concatenate the text of all agent_message_chunk updates. */
|
/** Concatenate the text of all agent_message_chunk updates. */
|
||||||
@@ -194,7 +193,7 @@ describe('acp bridge — session/load replay', () => {
|
|||||||
release() // resume() finishes AFTER teardown
|
release() // resume() finishes AFTER teardown
|
||||||
expect(await loadResult).toBe('rejected')
|
expect(await loadResult).toBe('rejected')
|
||||||
// No live agent was installed for the closed connection.
|
// 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 () => {
|
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 loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||||
await expect(loader.client.loadSession({ sessionId: 'elsewhere', cwd: process.cwd(), mcpServers: [] }))
|
await expect(loader.client.loadSession({ sessionId: 'elsewhere', cwd: process.cwd(), mcpServers: [] }))
|
||||||
.rejects.toThrow(/cwd mismatch/)
|
.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: [] })
|
const res = await loader.client.loadSession({ sessionId: 'elsewhere', cwd: `${otherCwd}/.`, mcpServers: [] })
|
||||||
expect(res).toBeDefined()
|
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 () => {
|
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
|
// 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
|
// the id is not wedged: a later attempt hits the same clean rejection, not a
|
||||||
// duplicate-registration error.
|
// 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: [] }))
|
await expect(loader.client.loadSession({ sessionId: 'legacy', cwd: process.cwd(), mcpServers: [] }))
|
||||||
.rejects.toThrow(/no absolute persisted cwd/)
|
.rejects.toThrow(/no absolute persisted cwd/)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
|||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
|
||||||
import { makeBridgeHarness, textResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts'
|
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. */
|
/** Text of the agent_message_chunk updates scoped to one session id. */
|
||||||
function messageTextFor(updates: { sessionId?: string; update: CapturedUpdate }[], sessionId: string): string {
|
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: {} })
|
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||||
const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
|
const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
|
||||||
const b = (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 agentA = harness.ctx.agents.get(SessionId(a))!
|
||||||
const agentB = harness.ctx.agents.get(AgentId(b))!
|
const agentB = harness.ctx.agents.get(SessionId(b))!
|
||||||
|
|
||||||
// Wait deterministically for BOTH agents to enter `running` (not a fixed
|
// Wait deterministically for BOTH agents to enter `running` (not a fixed
|
||||||
// sleep — agent startup latency is unbounded on a loaded worker).
|
// sleep — agent startup latency is unbounded on a loaded worker).
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
|||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
|
||||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||||
import {
|
import {
|
||||||
errorResponse,
|
errorResponse,
|
||||||
@@ -13,6 +12,7 @@ import {
|
|||||||
toolCallResponse,
|
toolCallResponse,
|
||||||
type BridgeHarness,
|
type BridgeHarness,
|
||||||
} from './harness.ts'
|
} from './harness.ts'
|
||||||
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||||
|
|
||||||
/** Boilerplate: initialize + create one session, returning its id. */
|
/** Boilerplate: initialize + create one session, returning its id. */
|
||||||
async function newSession(h: BridgeHarness, clientCapabilities: Record<string, unknown> = {}): Promise<string> {
|
async function newSession(h: BridgeHarness, clientCapabilities: Record<string, unknown> = {}): Promise<string> {
|
||||||
@@ -279,7 +279,7 @@ describe('acp bridge — turn outcomes', () => {
|
|||||||
// OWN turn with the real model answer.
|
// OWN turn with the real model answer.
|
||||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('real answer')] })
|
harness = await makeBridgeHarness({ storageDir, script: [textResponse('real answer')] })
|
||||||
const sessionId = await newSession(harness)
|
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
|
// On the queued prompt, synchronously inject a one-shot context turn (idle
|
||||||
// inject writes turn/start{injection} → context/message → turn/end). Fire
|
// inject writes turn/start{injection} → context/message → turn/end). Fire
|
||||||
// once so it lands between install and the prompt turn.
|
// once so it lands between install and the prompt turn.
|
||||||
@@ -335,7 +335,7 @@ describe('acp bridge — turn outcomes', () => {
|
|||||||
await harness.client.cancel({ sessionId })
|
await harness.client.cancel({ sessionId })
|
||||||
const res = await promptDone
|
const res = await promptDone
|
||||||
expect(res.stopReason).toBe('cancelled')
|
expect(res.stopReason).toBe('cancelled')
|
||||||
const agent = harness.ctx.agents.get(AgentId(sessionId))!
|
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||||
await agent.whenIdle()
|
await agent.whenIdle()
|
||||||
// At most ONE turn ran (the cancelled one) — the cancel cleared the queue, so
|
// 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
|
// no second turn was batched or leaked. (A best-effort abort that left queued
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import type { Context } from 'cordis'
|
|||||||
import { resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||||
import type { AgentHandle } from '@deepseek-ai/dsh-agent'
|
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 { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||||
import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent'
|
import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent'
|
||||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||||
@@ -255,7 +254,6 @@ export class HarnessSdkServer {
|
|||||||
|
|
||||||
private async createSession(sessionId: string): Promise<SessionRecord> {
|
private async createSession(sessionId: string): Promise<SessionRecord> {
|
||||||
const handle = await this.ctx.agents.create({
|
const handle = await this.ctx.agents.create({
|
||||||
agentId: AgentId(sessionId),
|
|
||||||
sessionId: SessionId(sessionId),
|
sessionId: SessionId(sessionId),
|
||||||
meta: { cwd: this.cwd },
|
meta: { cwd: this.cwd },
|
||||||
agentOptions: { model: this.model },
|
agentOptions: { model: this.model },
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { join } from 'node:path'
|
|||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { Context } from 'cordis'
|
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 { SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import * as agentCore from '@deepseek-ai/dsh-agent-core'
|
import * as agentCore from '@deepseek-ai/dsh-agent-core'
|
||||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||||
@@ -135,7 +136,6 @@ describe('HarnessSdkServer', () => {
|
|||||||
expect(llmServer.requests).toHaveLength(2)
|
expect(llmServer.requests).toHaveLength(2)
|
||||||
|
|
||||||
const orphanHandle = await ctx.agents.create({
|
const orphanHandle = await ctx.agents.create({
|
||||||
agentId: AgentId('orphan-agent'),
|
|
||||||
sessionId: SessionId('orphan-session'),
|
sessionId: SessionId('orphan-session'),
|
||||||
meta: { cwd: storageDir },
|
meta: { cwd: storageDir },
|
||||||
agentOptions: { model: 'dsagent-model' },
|
agentOptions: { model: 'dsagent-model' },
|
||||||
@@ -170,8 +170,8 @@ describe('HarnessSdkServer', () => {
|
|||||||
} as unknown as Agent
|
} as unknown as Agent
|
||||||
const mainHandle = { agent: mainAgent, dispose: vi.fn(() => Promise.resolve()) }
|
const mainHandle = { agent: mainAgent, dispose: vi.fn(() => Promise.resolve()) }
|
||||||
const otherHandle = { agent: otherAgent, dispose: vi.fn(() => Promise.resolve()) }
|
const otherHandle = { agent: otherAgent, dispose: vi.fn(() => Promise.resolve()) }
|
||||||
const create = vi.fn(async (options: { agentId: AgentId }) =>
|
const create = vi.fn(async (options: { sessionId: SessionId }) =>
|
||||||
String(options.agentId) === 'main' ? mainHandle : otherHandle)
|
String(options.sessionId) === 'main' ? mainHandle : otherHandle)
|
||||||
const ctx = {
|
const ctx = {
|
||||||
on: vi.fn(() => () => undefined),
|
on: vi.fn(() => () => undefined),
|
||||||
agents: { create, get: () => undefined },
|
agents: { create, get: () => undefined },
|
||||||
@@ -263,20 +263,18 @@ describe('HarnessSdkServer', () => {
|
|||||||
const server = new HarnessSdkServer(ctx, transport)
|
const server = new HarnessSdkServer(ctx, transport)
|
||||||
|
|
||||||
const parentHandle = await ctx.agents.create({
|
const parentHandle = await ctx.agents.create({
|
||||||
agentId: AgentId('parent-agent'),
|
|
||||||
sessionId: SessionId('main'),
|
sessionId: SessionId('main'),
|
||||||
meta: { cwd: storageDir },
|
meta: { cwd: storageDir },
|
||||||
agentOptions: { model: 'deepseek' },
|
agentOptions: { model: 'deepseek' },
|
||||||
})
|
})
|
||||||
const handle = await ctx.agents.create({
|
const handle = await ctx.agents.create({
|
||||||
agentId: AgentId('child-agent'),
|
|
||||||
sessionId: SessionId('child-session'),
|
sessionId: SessionId('child-session'),
|
||||||
meta: { cwd: storageDir, parentSession: SessionId('main') },
|
meta: { cwd: storageDir, parentSession: SessionId('main') },
|
||||||
agentOptions: { model: 'deepseek' },
|
agentOptions: { model: 'deepseek' },
|
||||||
})
|
})
|
||||||
await settleSubagent(ctx, parentHandle.agent, {
|
await settleSubagent(ctx, parentHandle.agent, {
|
||||||
provider: 'spawn',
|
provider: 'spawn',
|
||||||
id: AgentId('child-agent'),
|
id: SessionId('child-session'),
|
||||||
stopReason: 'completed',
|
stopReason: 'completed',
|
||||||
lastAssistantMessage: [{ type: 'text', text: 'child done' }],
|
lastAssistantMessage: [{ type: 'text', text: 'child done' }],
|
||||||
})
|
})
|
||||||
@@ -285,7 +283,7 @@ describe('HarnessSdkServer', () => {
|
|||||||
method: 'subagent.finished',
|
method: 'subagent.finished',
|
||||||
params: {
|
params: {
|
||||||
provider: 'spawn',
|
provider: 'spawn',
|
||||||
agentId: 'child-agent',
|
agentId: 'child-session',
|
||||||
parentSessionId: 'main',
|
parentSessionId: 'main',
|
||||||
childSessionId: 'child-session',
|
childSessionId: 'child-session',
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
@@ -311,19 +309,16 @@ describe('HarnessSdkServer', () => {
|
|||||||
let failedHandle: AgentHandle | undefined
|
let failedHandle: AgentHandle | undefined
|
||||||
try {
|
try {
|
||||||
parentHandle = await ctx.agents.create({
|
parentHandle = await ctx.agents.create({
|
||||||
agentId: AgentId('fallback-parent-agent'),
|
|
||||||
sessionId: SessionId('fallback-parent'),
|
sessionId: SessionId('fallback-parent'),
|
||||||
meta: { cwd: storageDir },
|
meta: { cwd: storageDir },
|
||||||
agentOptions: { model: 'deepseek' },
|
agentOptions: { model: 'deepseek' },
|
||||||
})
|
})
|
||||||
handle = await ctx.agents.create({
|
handle = await ctx.agents.create({
|
||||||
agentId: AgentId('fallback-child-agent'),
|
|
||||||
sessionId: SessionId('fallback-child-session'),
|
sessionId: SessionId('fallback-child-session'),
|
||||||
meta: { cwd: storageDir, parentSession: SessionId('fallback-parent') },
|
meta: { cwd: storageDir, parentSession: SessionId('fallback-parent') },
|
||||||
agentOptions: { model: 'deepseek' },
|
agentOptions: { model: 'deepseek' },
|
||||||
})
|
})
|
||||||
failedHandle = await ctx.agents.create({
|
failedHandle = await ctx.agents.create({
|
||||||
agentId: AgentId('failed-child-agent'),
|
|
||||||
sessionId: SessionId('failed-child-session'),
|
sessionId: SessionId('failed-child-session'),
|
||||||
meta: { cwd: storageDir },
|
meta: { cwd: storageDir },
|
||||||
agentOptions: { model: 'deepseek' },
|
agentOptions: { model: 'deepseek' },
|
||||||
@@ -333,18 +328,18 @@ describe('HarnessSdkServer', () => {
|
|||||||
|
|
||||||
await settleSubagent(ctx, parentHandle.agent, {
|
await settleSubagent(ctx, parentHandle.agent, {
|
||||||
provider: 'fork',
|
provider: 'fork',
|
||||||
id: AgentId('fallback-child-agent'),
|
id: SessionId('fallback-child-session'),
|
||||||
stopReason: 'max-tokens',
|
stopReason: 'max-tokens',
|
||||||
lastAssistantMessage: [],
|
lastAssistantMessage: [],
|
||||||
})
|
})
|
||||||
await settleSubagent(ctx, parentHandle.agent, {
|
await settleSubagent(ctx, parentHandle.agent, {
|
||||||
provider: 'fork',
|
provider: 'fork',
|
||||||
id: AgentId('failed-child-agent'),
|
id: SessionId('failed-child-session'),
|
||||||
stopReason: 'error',
|
stopReason: 'error',
|
||||||
})
|
})
|
||||||
await settleSubagent(ctx, parentHandle.agent, {
|
await settleSubagent(ctx, parentHandle.agent, {
|
||||||
provider: 'fork',
|
provider: 'fork',
|
||||||
id: AgentId('missing-child-agent'),
|
id: SessionId('missing-child-agent'),
|
||||||
stopReason: 'error',
|
stopReason: 'error',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -352,7 +347,7 @@ describe('HarnessSdkServer', () => {
|
|||||||
method: 'subagent.finished',
|
method: 'subagent.finished',
|
||||||
params: {
|
params: {
|
||||||
provider: 'fork',
|
provider: 'fork',
|
||||||
agentId: 'fallback-child-agent',
|
agentId: 'fallback-child-session',
|
||||||
parentSessionId: 'fallback-parent',
|
parentSessionId: 'fallback-parent',
|
||||||
childSessionId: 'fallback-child-session',
|
childSessionId: 'fallback-child-session',
|
||||||
status: 'error',
|
status: 'error',
|
||||||
@@ -364,7 +359,7 @@ describe('HarnessSdkServer', () => {
|
|||||||
method: 'subagent.finished',
|
method: 'subagent.finished',
|
||||||
params: {
|
params: {
|
||||||
provider: 'fork',
|
provider: 'fork',
|
||||||
agentId: 'failed-child-agent',
|
agentId: 'failed-child-session',
|
||||||
childSessionId: 'failed-child-session',
|
childSessionId: 'failed-child-session',
|
||||||
status: 'error',
|
status: 'error',
|
||||||
stopReason: 'error',
|
stopReason: 'error',
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ A terminal chat always wants the same cluster, so the package owns it rather tha
|
|||||||
| Plugin | Why it is here |
|
| 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) |
|
| `@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-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-user-interaction` | the human question/answer seam used by confirmation tools |
|
||||||
| `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool |
|
| `@deepseek-ai/dsh-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`.
|
`@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 |
|
| 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` |
|
| `persona` | — | the deployment persona template (may reference `{{model}}`), routed to `dsh-system-prompt` |
|
||||||
| `toolOrder` | — | explicit model-facing tool order (a name list with one `'<unlisted-tools>'` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` |
|
| `toolOrder` | — | explicit model-facing tool order (a name list with one `'<unlisted-tools>'` 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 |
|
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
|
||||||
| `welcome` | `ready.` | the stdin-chat banner |
|
| `welcome` | `ready.` | the stdin-chat banner |
|
||||||
| `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) |
|
| `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-<uuid>` 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
|
## The bin
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
* @deepseek-ai/dsh-agent-core}) plus the coupled front-door cluster a terminal
|
* @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`
|
* chat needs — a console logger, the readline UI (the in-package `stdio-chat`
|
||||||
* module), JSONL session
|
* 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
|
* 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
|
* console (stdout is just the terminal) and always pre-creates one agent the
|
||||||
* the readline UI sends to. The leaf supplies the swappable backends (the LLM
|
* readline UI labels `main`. The leaf supplies the swappable backends (the LLM
|
||||||
* adapter, the bash executor), optional product tools, the optional `hmr`
|
* adapter, the bash executor), optional product tools, the optional `hmr`
|
||||||
* dev-reload plugin, and this app's {@link Config} (model, prompt, persistence
|
* dev-reload plugin, and this app's {@link Config} (model, prompt, persistence
|
||||||
* root, welcome banner).
|
* root, welcome banner).
|
||||||
@@ -41,7 +41,6 @@
|
|||||||
import type { Context } from 'cordis'
|
import type { Context } from 'cordis'
|
||||||
import ConsoleExporter from '@cordisjs/plugin-logger-console'
|
import ConsoleExporter from '@cordisjs/plugin-logger-console'
|
||||||
import z from 'schemastery'
|
import z from 'schemastery'
|
||||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
|
||||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
|
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
|
||||||
import * as agentCore from '@deepseek-ai/dsh-agent-core'
|
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
|
* 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
|
* {@link @deepseek-ai/dsh-agent-core}'s forwarded `agents` list); `persona` is
|
||||||
* the deployment persona (forwarded to the system-prompt plugin); `toolOrder`
|
* the deployment persona (forwarded to the system-prompt plugin); `toolOrder`
|
||||||
* is the explicit model-facing tool order (forwarded to the system-prompt plugin);
|
* 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.
|
* `welcome` is the UI banner.
|
||||||
*/
|
*/
|
||||||
export interface Config {
|
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
|
model: string
|
||||||
/** Deployment persona (the system-prompt plugin's `persona` config). */
|
/** Deployment persona (the system-prompt plugin's `persona` config). */
|
||||||
persona?: string
|
persona?: string
|
||||||
@@ -78,7 +77,7 @@ export interface Config {
|
|||||||
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */
|
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */
|
||||||
skills?: agentCore.SkillConfig
|
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`
|
* starting fresh. Sourced from an env var in the leaf `cordis.yml`
|
||||||
* (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`).
|
* (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`).
|
||||||
*/
|
*/
|
||||||
@@ -103,9 +102,9 @@ export const Config: z<Config> = z.object({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Compose the spine with the stdio front door. The console logger comes first
|
* 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
|
* (infra), then the agent-core bundle pre-creating one agent from this app's
|
||||||
* app's `model`/`resumeSessionId` with the deployment `persona`, then the JSONL
|
* `model`/`resumeSessionId` with the deployment `persona`, then the JSONL
|
||||||
* backend, then the readline UI bound to `main`. The `hmr` dev-reload plugin is
|
* 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.
|
* a leaf concern (see the module doc), so it is not mounted here.
|
||||||
*/
|
*/
|
||||||
export function apply(ctx: Context, config: Config): void {
|
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.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
|
||||||
...config.tools !== undefined ? { tools: config.tools } : {},
|
...config.tools !== undefined ? { tools: config.tools } : {},
|
||||||
agents: [{
|
agents: [{
|
||||||
id: AgentId('main'),
|
id: 'main',
|
||||||
model: config.model,
|
model: config.model,
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {},
|
...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(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
|
||||||
ctx.plugin(UserInteractionService)
|
ctx.plugin(UserInteractionService)
|
||||||
ctx.plugin(toolAskUser)
|
ctx.plugin(toolAskUser)
|
||||||
ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.', agent: 'main' })
|
ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { createInterface } from 'node:readline'
|
|||||||
import type { Readable, Writable } from 'node:stream'
|
import type { Readable, Writable } from 'node:stream'
|
||||||
import type { Context } from 'cordis'
|
import type { Context } from 'cordis'
|
||||||
import z from 'schemastery'
|
import z from 'schemastery'
|
||||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||||
import {
|
import {
|
||||||
UserInteractionError,
|
UserInteractionError,
|
||||||
type AskUserQuestionAnswer,
|
type AskUserQuestionAnswer,
|
||||||
@@ -36,15 +36,10 @@ export const inject = ['agents', 'userInteraction']
|
|||||||
export interface Config {
|
export interface Config {
|
||||||
/** Banner printed once on start, before the first `> ` prompt. */
|
/** Banner printed once on start, before the first `> ` prompt. */
|
||||||
welcome?: string
|
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<Config> = z.object({
|
export const Config: z<Config> = z.object({
|
||||||
welcome: z.string().default('ready.'),
|
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
|
// Loader validation, so it must be self-contained rather than trusting the
|
||||||
// cast — `config.welcome as string` would otherwise be `undefined` on `{}`.
|
// cast — `config.welcome as string` would otherwise be `undefined` on `{}`.
|
||||||
const welcome = config.welcome ?? 'ready.'
|
const welcome = config.welcome ?? 'ready.'
|
||||||
const agentId = AgentId(config.agent ?? 'main')
|
|
||||||
const { input, output, exit } = runtime
|
const { input, output, exit } = runtime
|
||||||
|
|
||||||
// Render label lookup: the `turn/start` session event carries only the turn
|
// This app owns exactly one pre-created agent. Hold the live object directly:
|
||||||
// number, so to print the short agent id (`[main turn 1]`) we map the
|
// its per-run id is intentionally fresh, while `main` remains only the
|
||||||
// session's id to its agent's id. The session id is not reliably the agent id
|
// terminal's fixed display label.
|
||||||
// (a session can be created with an explicit/client-supplied id), so build the
|
let target: Agent | undefined = ctx.agents.list()[0]
|
||||||
// map from `agent/created` rather than parsing the id string. Seed from the
|
ctx.on('agent/created', (agent) => { target ??= agent })
|
||||||
// registry's current agents first: an agent registered before this plugin
|
ctx.on('agent/disposed', (agent) => {
|
||||||
// installed (e.g. the pre-created `main` agent, or any agent surviving an HMR
|
if (target === agent) target = undefined
|
||||||
// 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<string, string>()
|
|
||||||
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) })
|
|
||||||
|
|
||||||
// Transcript rendering off the durable `session/event` feed — the assistant
|
// Transcript rendering off the durable `session/event` feed — the assistant
|
||||||
// token stream, turn/step boundaries, tool activity, and todos all come from
|
// 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)
|
output.write(chunk.text)
|
||||||
}
|
}
|
||||||
} else if (event.type === 'turn/start') {
|
} 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}] `)
|
output.write(`\n[${label} turn ${event.data.turn}] `)
|
||||||
} else if (event.type === 'turn/end') {
|
} else if (event.type === 'turn/end') {
|
||||||
if (inReasoning) output.write('\x1B[0m')
|
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.
|
// Work submitted: wait until a turn has run and the agent is idle.
|
||||||
if (submittedWork) {
|
if (submittedWork) {
|
||||||
if (!sawRunning) return
|
if (!sawRunning) return
|
||||||
const agent = ctx.agents.get(agentId)
|
const agent = target
|
||||||
if (agent && agent.status !== 'idle') return // a turn is still running
|
if (agent && agent.status !== 'idle') return // a turn is still running
|
||||||
}
|
}
|
||||||
// Let any final output flush, then exit. The handle is tracked so the
|
// 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) => {
|
const disposeStatusListener = ctx.on('agent/status', (subject, status) => {
|
||||||
if (subject.id !== agentId) return
|
if (subject !== target) return
|
||||||
if (status === 'running') sawRunning = true
|
if (status === 'running') sawRunning = true
|
||||||
if (status === 'idle') maybeExit()
|
if (status === 'idle') maybeExit()
|
||||||
})
|
})
|
||||||
@@ -354,9 +342,9 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
|
|||||||
}
|
}
|
||||||
const text = line.trim()
|
const text = line.trim()
|
||||||
if (!text) return
|
if (!text) return
|
||||||
const agent = ctx.agents.get(agentId)
|
const agent = target
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
ctx.logger.error('ui-stdio: agent "%s" is not running', agentId)
|
ctx.logger.error('ui-stdio: main agent is not running')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
submittedWork = true
|
submittedWork = true
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function fakeContext(): Context {
|
|||||||
return {
|
return {
|
||||||
on: vi.fn(() => vi.fn()),
|
on: vi.fn(() => vi.fn()),
|
||||||
effect: vi.fn((callback: () => () => void) => callback()),
|
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.
|
// exercises readline terminal-mode selection, so an empty roster suffices.
|
||||||
agents: { list: vi.fn(() => []) },
|
agents: { list: vi.fn(() => []) },
|
||||||
userInteraction: { registerProvider: vi.fn(() => vi.fn()) },
|
userInteraction: { registerProvider: vi.fn(() => vi.fn()) },
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { join } from 'node:path'
|
|||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import Loader from '@cordisjs/plugin-loader'
|
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 type { Message } from '@deepseek-ai/dsh-llm'
|
||||||
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
||||||
import * as stdioAgent from '../src/index.ts'
|
import * as stdioAgent from '../src/index.ts'
|
||||||
@@ -82,9 +83,12 @@ describe('dsh-stdio-agent app', () => {
|
|||||||
expect(ctx.get('sessionPersistence')).toBeDefined()
|
expect(ctx.get('sessionPersistence')).toBeDefined()
|
||||||
expect(ctx.get('userInteraction')).toBeDefined()
|
expect(ctx.get('userInteraction')).toBeDefined()
|
||||||
expect(ctx.get('tools')?.get('ask_user_question')).toBeDefined()
|
expect(ctx.get('tools')?.get('ask_user_question')).toBeDefined()
|
||||||
// The pre-created `main` agent the UI drives.
|
// The sole pre-created agent the UI drives. `main` is its stable config
|
||||||
const agent = ctx.get('agents')?.get(AgentId('main'))
|
// label; each fresh process mints a durable combined agent/session id.
|
||||||
|
const agent = ctx.get('agents')?.list()[0]
|
||||||
expect(agent).toBeDefined()
|
expect(agent).toBeDefined()
|
||||||
|
expect(agent?.id).toBe(agent?.session.id)
|
||||||
|
expect(agent?.id).toMatch(/^main-session-/)
|
||||||
expect(agent?.session.header.cwd).toBe(process.cwd())
|
expect(agent?.session.header.cwd).toBe(process.cwd())
|
||||||
await ctx.fiber.dispose()
|
await ctx.fiber.dispose()
|
||||||
})
|
})
|
||||||
@@ -99,7 +103,7 @@ describe('dsh-stdio-agent app', () => {
|
|||||||
stdioAgent.apply(ctx, { model: 'mock', skills: await isolatedSkillsConfig() })
|
stdioAgent.apply(ctx, { model: 'mock', skills: await isolatedSkillsConfig() })
|
||||||
await new Promise(resolve => setTimeout(resolve, 80))
|
await new Promise(resolve => setTimeout(resolve, 80))
|
||||||
expect(ctx.get('sessionPersistence')).toBeDefined()
|
expect(ctx.get('sessionPersistence')).toBeDefined()
|
||||||
expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
|
expect(ctx.get('agents')?.list()).toHaveLength(1)
|
||||||
await ctx.fiber.dispose()
|
await ctx.fiber.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -116,7 +120,7 @@ describe('dsh-stdio-agent app', () => {
|
|||||||
|
|
||||||
it('forwards resumeSessionId onto the pre-created agent when set', async () => {
|
it('forwards resumeSessionId onto the pre-created agent when set', async () => {
|
||||||
// A resume id defers agent creation until persistence loads; with no backing
|
// 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.
|
// the branch that maps resumeSessionId through is what this covers.
|
||||||
const ctx = await mount({
|
const ctx = await mount({
|
||||||
model: 'mock',
|
model: 'mock',
|
||||||
@@ -125,7 +129,7 @@ describe('dsh-stdio-agent app', () => {
|
|||||||
resumeSessionId: 'no-such-session',
|
resumeSessionId: 'no-such-session',
|
||||||
skills: await isolatedSkillsConfig(),
|
skills: await isolatedSkillsConfig(),
|
||||||
})
|
})
|
||||||
expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined()
|
expect(ctx.get('agents')?.list()).toEqual([])
|
||||||
await ctx.fiber.dispose()
|
await ctx.fiber.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -57,17 +57,16 @@ function makeAgent(id: string, status: AgentStatus = 'idle'): Agent & {
|
|||||||
status,
|
status,
|
||||||
sent,
|
sent,
|
||||||
steered,
|
steered,
|
||||||
// A minimal session stub: the UI reads only `session.header.id` (to map the
|
// A minimal session stub with the agent's shared durable identity.
|
||||||
// session back to its agent id for the turn-boundary label).
|
session: { id, header: { id } },
|
||||||
session: { header: { id: `${id}-session` } },
|
|
||||||
send: (content: ContentBlock[]) => void sent.push(content),
|
send: (content: ContentBlock[]) => void sent.push(content),
|
||||||
steer: (content: ContentBlock[]) => void steered.push(content),
|
steer: (content: ContentBlock[]) => void steered.push(content),
|
||||||
} as never
|
} as never
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A session stub whose `header.id` matches an agent's, for `session/event` emits. */
|
/** A session stub whose `header.id` matches an agent's, for `session/event` emits. */
|
||||||
function makeSession(agentId: string): Session {
|
function makeSession(id: string): Session {
|
||||||
return { header: { id: `${agentId}-session` } } as Session
|
return { id, header: { id } } as Session
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An `assistant/chunk` session event carrying one raw stream chunk. */
|
/** 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 } }
|
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<StdioRuntime> = {}) {
|
async function setup(config: Config = CONFIG, runtimeOver: Partial<StdioRuntime> = {}) {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
@@ -99,12 +98,11 @@ describe('createStdioChat rendering', () => {
|
|||||||
expect(out.text()).toBe('hi there\n> ')
|
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
|
// 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({})
|
const { out } = await setup({})
|
||||||
expect(out.text()).toBe('ready.\n> ')
|
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 () => {
|
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 () => {
|
it('renders turn/start and turn/end markers from the session feed', async () => {
|
||||||
const { ctx, out } = await setup()
|
const { ctx, out } = await setup()
|
||||||
const agent = makeAgent('main')
|
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)
|
ctx.emit('agent/created', agent)
|
||||||
const session = makeSession('main')
|
const session = agent.session
|
||||||
ctx.emit('session/event', session, {
|
ctx.emit('session/event', session, {
|
||||||
type: 'turn/start', seq: 1, time: 0, data: { turn: 3, trigger: { kind: 'message' } },
|
type: 'turn/start', seq: 1, time: 0, data: { turn: 3, trigger: { kind: 'message' } },
|
||||||
} as SessionEvent)
|
} as SessionEvent)
|
||||||
@@ -169,21 +167,20 @@ describe('createStdioChat rendering', () => {
|
|||||||
expect(out.text()).toContain('\n> ')
|
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()
|
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'), {
|
ctx.emit('session/event', makeSession('orphan'), {
|
||||||
type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } },
|
type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } },
|
||||||
} as SessionEvent)
|
} 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
|
// 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
|
// 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
|
// 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
|
// install time preserves the terminal's fixed `[main turn N]` label.
|
||||||
// of the raw session id.
|
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(AgentRegistry)
|
await ctx.plugin(AgentRegistry)
|
||||||
await ctx.plugin(UserInteractionService)
|
await ctx.plugin(UserInteractionService)
|
||||||
@@ -193,7 +190,7 @@ describe('createStdioChat rendering', () => {
|
|||||||
await ctx.plugin(Object.assign((inner: Context) => {
|
await ctx.plugin(Object.assign((inner: Context) => {
|
||||||
createStdioChat(inner, CONFIG, runtime)
|
createStdioChat(inner, CONFIG, runtime)
|
||||||
}, { inject: ['agents', 'userInteraction'] }))
|
}, { 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' } },
|
type: 'turn/start', seq: 1, time: 0, data: { turn: 5, trigger: { kind: 'message' } },
|
||||||
} as SessionEvent)
|
} as SessionEvent)
|
||||||
expect(out.text()).toContain('[main turn 5] ')
|
expect(out.text()).toContain('[main turn 5] ')
|
||||||
@@ -209,17 +206,28 @@ describe('createStdioChat rendering', () => {
|
|||||||
expect(out.text()).toContain('\x1B[2mmid\x1B[0m')
|
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 { ctx, out } = await setup()
|
||||||
const agent = makeAgent('main')
|
const agent = makeAgent('main')
|
||||||
ctx.emit('agent/created', agent)
|
ctx.emit('agent/created', agent)
|
||||||
ctx.emit('agent/disposed', agent)
|
ctx.emit('agent/disposed', agent)
|
||||||
// After disposal the map no longer resolves the agent id — fall back to the
|
// After disposal the event belongs to a non-target session, so its durable
|
||||||
// session header id.
|
// identity is rendered directly.
|
||||||
ctx.emit('session/event', makeSession('main'), {
|
ctx.emit('session/event', agent.session, {
|
||||||
type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } },
|
type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } },
|
||||||
} as SessionEvent)
|
} 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 () => {
|
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(() => {})
|
const spy = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
|
||||||
input.feed('nobody home')
|
input.feed('nobody home')
|
||||||
await new Promise(r => setImmediate(r))
|
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 () => {
|
it('drives the app-owned agent without a duplicate id config', async () => {
|
||||||
const { ctx, input } = await setup({ welcome: 'w', agent: 'worker' })
|
const { ctx, input } = await setup({ welcome: 'w' })
|
||||||
const agent = makeAgent('worker')
|
const agent = makeAgent('worker')
|
||||||
ctx.agents.register(agent)
|
ctx.agents.register(agent)
|
||||||
input.feed('hi')
|
input.feed('hi')
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ The `Branded<B>` nominal-typing primitive — a tiny, **type-only** package (no
|
|||||||
|
|
||||||
## What `Branded` is
|
## 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
|
```ts
|
||||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
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
|
## 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`.
|
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`.
|
||||||
|
|||||||
@@ -4,15 +4,15 @@
|
|||||||
* cross-boundary id.
|
* cross-boundary id.
|
||||||
*
|
*
|
||||||
* A brand makes structurally-identical strings non-interchangeable at the type
|
* 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
|
* 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);
|
* factory in the OWNING package (a plain cast inside — zero runtime cost);
|
||||||
* comparison, logging, and serialization all behave as ordinary strings.
|
* comparison, logging, and serialization all behave as ordinary strings.
|
||||||
*
|
*
|
||||||
* Policy: a package brands the ids it owns — `CallId` in dsh-llm (tool-call
|
* Policy: a package brands the ids it owns — `CallId` in dsh-llm (tool-call
|
||||||
* correlation), `SessionId` in dsh-session, `AgentId` in dsh-agent,
|
* correlation), and `SessionId` in dsh-session; `BashTaskId`/`OwnerToken` live
|
||||||
* `BashTaskId`/`OwnerToken` in dsh-bash. Branding is for ids that cross package
|
* in dsh-bash. Branding is for ids that cross package boundaries and could
|
||||||
* boundaries and could plausibly be confused; not every string needs a brand.
|
* plausibly be confused; not every string needs a brand.
|
||||||
* This package owns ONLY the primitive — no concrete id, no runtime code beyond
|
* 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
|
* the (erased) type — so the brand vocabulary stays dependency-free and a
|
||||||
* package can brand its ids without depending on an unrelated capability
|
* package can brand its ids without depending on an unrelated capability
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import Loader from '@cordisjs/plugin-loader'
|
|||||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||||
import type { ToolExecutionResult } 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 type { Agent } from '@deepseek-ai/dsh-agent'
|
||||||
import { WorkflowRunId, WorkflowService } from '@deepseek-ai/dsh-workflow'
|
import { WorkflowRunId, WorkflowService } from '@deepseek-ai/dsh-workflow'
|
||||||
import type { WorkflowResult, WorkflowRun, WorkflowStartRequest } 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 SubagentService from '@deepseek-ai/dsh-subagent'
|
||||||
import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread'
|
import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread'
|
||||||
import * as toolWorkflow from '../src/index.ts'
|
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). */
|
/** A controllable engine standing in behind ctx.workflows (the tool's only seam). */
|
||||||
class StubEngine extends WorkflowService {
|
class StubEngine extends WorkflowService {
|
||||||
@@ -51,7 +51,7 @@ async function setup(config?: { toolName?: string; maxResultChars?: number }) {
|
|||||||
await ctx.plugin(StubEngine)
|
await ctx.plugin(StubEngine)
|
||||||
await ctx.plugin(toolWorkflow, config ?? {})
|
await ctx.plugin(toolWorkflow, config ?? {})
|
||||||
const engine = ctx.workflows as StubEngine
|
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 }
|
return { ctx, engine, parent }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +237,7 @@ describe('dsh-tool-workflow', () => {
|
|||||||
await ctx.plugin(SubagentService)
|
await ctx.plugin(SubagentService)
|
||||||
await ctx.plugin(WorkerWorkflowEngine, { disposeGraceMs: 30 })
|
await ctx.plugin(WorkerWorkflowEngine, { disposeGraceMs: 30 })
|
||||||
await ctx.plugin(toolWorkflow, {})
|
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 controller = new AbortController()
|
||||||
const pending = execute(ctx, {
|
const pending = execute(ctx, {
|
||||||
script: 'await new Promise(() => {})\nreturn 1',
|
script: 'await new Promise(() => {})\nreturn 1',
|
||||||
|
|||||||
@@ -38,8 +38,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as vm from 'node:vm'
|
import * as vm from 'node:vm'
|
||||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
|
||||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||||
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import { assertSupportedOutputSchema, OutputSchemaError } from '@deepseek-ai/dsh-tools'
|
import { assertSupportedOutputSchema, OutputSchemaError } from '@deepseek-ai/dsh-tools'
|
||||||
import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
|
import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
|
||||||
import { isFatalWorkflowError, WorkflowError } from '@deepseek-ai/dsh-workflow'
|
import { isFatalWorkflowError, WorkflowError } from '@deepseek-ai/dsh-workflow'
|
||||||
@@ -319,7 +319,7 @@ export class WorkflowExecution {
|
|||||||
await run.dispose()
|
await run.dispose()
|
||||||
throw this.cancelledError()
|
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)
|
this.observer.agentStart(info)
|
||||||
try {
|
try {
|
||||||
let result
|
let result
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user