Merge remote-tracking branch 'origin/feat/send-unify' into xtr/agent-loop-message-machine

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md
#	.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md
#	docs/architecture.i18n.yaml
#	docs/architecture.md
#	docs/architecture.zh.md
#	docs/core-data-structures/core.md
#	packages/context/session-reference/README.md
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/src/agent.ts
#	packages/core/agent-loop/src/inbox.ts
#	packages/core/agent-loop/tests/agent.spec.ts
#	packages/core/agent-loop/tests/cancel.spec.ts
#	packages/core/agent-loop/tests/contract-regressions.spec.ts
#	packages/core/agent-loop/tests/coverage-edges.spec.ts
#	packages/core/agent-loop/tests/interception.spec.ts
#	packages/core/agent-loop/tests/loop.spec.ts
#	packages/core/agent/README.md
#	packages/core/agent/src/types.ts
#	packages/core/agent/tests/agent.spec.ts
#	packages/ui/acp/src/index.ts
#	packages/ui/tui/src/index.ts
#	packages/ui/tui/tests/harness.ts
This commit is contained in:
_Kerman
2026-07-24 16:25:53 +08:00
74 changed files with 310 additions and 303 deletions

View File

@@ -17,7 +17,7 @@ sequenceDiagram
participant Session participant Session
participant Persistence participant Persistence
participant SDK as UI or SDK listener participant SDK as UI or SDK listener
User->>Agent: send(content) User->>Agent: followup(content)
Agent-->>SDK: <code>agent/inbox/enqueue</code> Agent-->>SDK: <code>agent/inbox/enqueue</code>
Agent->>Driver: queued work wakes driver Agent->>Driver: queued work wakes driver
Driver-->>SDK: <code>agent/status</code> running Driver-->>SDK: <code>agent/status</code> running

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
extension-cookbook.md: 056be4298ed2bec2b78ed777d58f1f8a60a34b78 extension-cookbook.md: c13b46e06a3b34512cd371e6a4868a6e932a575f
extension-cookbook.zh.md: 41cdd4a7d14f32494d1dd5ae4a63c098d5640bdc extension-cookbook.zh.md: aeb5f905278c07344c68d80da05dc5daf299b4f6

View File

@@ -36,7 +36,7 @@ This waterfall is the reorderable policy layer. Use `ctx.tools.guard()` when an
## A UI plugin ## A UI plugin
A UI plugin renders from the `session/event` feed (the assistant token stream as `assistant/chunk`, plus turn/step boundaries and tool activity), and drives input back in via `agent.send()` / `agent.steer()`. A UI plugin renders from the `session/event` feed (the assistant token stream as `assistant/chunk`, plus turn/step boundaries and tool activity), and drives input back in via `agent.followup()` / `agent.steer()`.
```ts ```ts
import type { Context } from 'cordis' import type { Context } from 'cordis'
@@ -54,13 +54,13 @@ export function apply(ctx: Context) {
render(event.data.chunk.text) render(event.data.chunk.text)
} }
}) })
onUserInput(text => ctx.agents.get(SessionId('client-session'))?.send([{ type: 'text', text }])) onUserInput(text => ctx.agents.get(SessionId('client-session'))?.followup([{ type: 'text', text }]))
} }
``` ```
## A client-driver plugin (external protocol bridge) ## A client-driver plugin (external protocol bridge)
A *client driver* is a UI plugin for a wire-protocol peer. It owns stdio, so stdout logging must be disabled, creates or resumes agents through the factory, maps harness events to protocol messages, and maps requests to `send()` or `cancel()`. Settle each request exactly once from durable `turn/end`, even if rendering fails, and tear agents down with `AgentHandle.dispose()` so disposal reaches quiescence. A *client driver* is a UI plugin for a wire-protocol peer. It owns stdio, so stdout logging must be disabled, creates or resumes agents through the factory, maps harness events to protocol messages, and maps requests to `followup()` or `cancel()`. Settle each request exactly once from durable `turn/end`, even if rendering fails, and tear agents down with `AgentHandle.dispose()` so disposal reaches quiescence.
`packages/ui/acp` is the worked example: it bridges the agent to the Agent Client Protocol (JSON-RPC over stdio) so Zed and other ACP editors can drive it. See its README for the full method surface and the permission-prompt answerer it registers on the approval seam. `packages/ui/acp` is the worked example: it bridges the agent to the Agent Client Protocol (JSON-RPC over stdio) so Zed and other ACP editors can drive it. See its README for the full method surface and the permission-prompt answerer it registers on the approval seam.
@@ -99,9 +99,9 @@ Every product feature maps to a listener on a documented extension seam — the
|---|---| |---|---|
| Hook system (user + project level) | listeners on `agent/session-start`, `agent/prompt-submit`, `agent/request`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation` — each interception waterfall returns a typed Decision; the `dsh-hooks-claude` / `dsh-hooks-codex` bridges map hook config files onto these seams | | Hook system (user + project level) | listeners on `agent/session-start`, `agent/prompt-submit`, `agent/request`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation` — each interception waterfall returns a typed Decision; the `dsh-hooks-claude` / `dsh-hooks-codex` bridges map hook config files onto these seams |
| `/goal` | `ctx.goals` owns durable state, `dsh-goal-session` schedules same-session rounds through the public `Agent`, and separate command/tool producers expose human/model control | | `/goal` | `ctx.goals` owns durable state, `dsh-goal-session` schedules same-session rounds through the public `Agent`, and separate command/tool producers expose human/model control |
| `/loop` | on the `turn/end` session event, `send()` the next iteration; or force-continue | | `/loop` | on the `turn/end` session event, `followup()` the next iteration; or force-continue |
| Dynamic workflow | `ctx.workflows` + the worker-thread engine + the `workflow` tool; structured in-process children enforce output with scoped prompt/tool registrations, a monotonic tool guard, final `tools/result` commit (including enclosing `run_code`), and terminal `agent/turn-stop` | | Dynamic workflow | `ctx.workflows` + the worker-thread engine + the `workflow` tool; structured in-process children enforce output with scoped prompt/tool registrations, a monotonic tool guard, final `tools/result` commit (including enclosing `run_code`), and terminal `agent/turn-stop` |
| Queued + steering messages | core `Agent.send()` / `Agent.steer()` | | Queued + steering messages | core `Agent.followup()` / `Agent.steer()` |
| Context compaction (auto + manual) | the `ctx.compact` seam + `dsh-compact-basic`; automatic pressure runs on serial `agent/post-step`, canonical overflow recovery runs on `agent/request-error`, and manual callers use the same compact service ([compaction Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) — the model-facing `/compact` consumer tool is deferred) | | Context compaction (auto + manual) | the `ctx.compact` seam + `dsh-compact-basic`; automatic pressure runs on serial `agent/post-step`, canonical overflow recovery runs on `agent/request-error`, and manual callers use the same compact service ([compaction Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) — the model-facing `/compact` consumer tool is deferred) |
| System prompt configurability | `ctx.systemPrompt.section()` with ordering and scope-local shadowing | | System prompt configurability | `ctx.systemPrompt.section()` with ordering and scope-local shadowing |
| AGENTS.md (root) | a section provider reading the file | | AGENTS.md (root) | a section provider reading the file |
@@ -118,8 +118,8 @@ Every product feature maps to a listener on a documented extension seam — the
| MCP | one plugin per server: discover tools → `ctx.tools.register()` | | MCP | one plugin per server: discover tools → `ctx.tools.register()` |
| Skills | section + tool registration; `inject()` skill content on invocation | | Skills | section + tool registration; `inject()` skill content on invocation |
| Memory | section provider + tool | | Memory | section provider + tool |
| Scheduled tasks (cron) | a plugin registers model-callable scheduling tools; timer fires → `send(…, {source: {kind: 'cron', …}})` when idle / `inject()` notification when busy | | Scheduled tasks (cron) | a plugin registers model-callable scheduling tools; timer fires → `followup(…, {source: {kind: 'cron', …}})` when idle / `inject()` notification when busy |
| UI (GUI; CLI emits JSONL) | listen `session/event` (assistant chunks, boundaries, tool activity); input → `send()` | | UI (GUI; CLI emits JSONL) | listen `session/event` (assistant chunks, boundaries, tool activity); input → `followup()` |
| Telemetry / replayable trace | `session/event` → JSONL; replay = `sessions.create(id, { seed })` | | Telemetry / replayable trace | `session/event` → JSONL; replay = `sessions.create(id, { seed })` |
| Model adapters | `LlmAdapter` subclass via `registerAdapter` (`dsh-llm-deepseek`, `dsh-llm-pi-ai`) | | Model adapters | `LlmAdapter` subclass via `registerAdapter` (`dsh-llm-deepseek`, `dsh-llm-pi-ai`) |
| Plugin hot-reload | every registration is a `ctx.effect` → vendored HMR just works | | Plugin hot-reload | every registration is a `ctx.effect` → vendored HMR just works |

View File

@@ -36,7 +36,7 @@ export function apply(ctx: Context) {
## UI 插件 ## UI 插件
UI 插件从 `session/event` 事件流渲染(助手 token 流以 `assistant/chunk` 形式到达,加上轮次/步骤边界与工具活动),并通过 `agent.send()` / `agent.steer()` 将输入驱动回去。 UI 插件从 `session/event` 事件流渲染(助手 token 流以 `assistant/chunk` 形式到达,加上轮次/步骤边界与工具活动),并通过 `agent.followup()` / `agent.steer()` 将输入驱动回去。
```ts ```ts
import type { Context } from 'cordis' import type { Context } from 'cordis'
@@ -54,13 +54,13 @@ export function apply(ctx: Context) {
render(event.data.chunk.text) render(event.data.chunk.text)
} }
}) })
onUserInput(text => ctx.agents.get(SessionId('client-session'))?.send([{ type: 'text', text }])) onUserInput(text => ctx.agents.get(SessionId('client-session'))?.followup([{ type: 'text', text }]))
} }
``` ```
## 客户端驱动插件(外部协议桥接) ## 客户端驱动插件(外部协议桥接)
*客户端驱动*是面向协议格式(wire format)对端的 UI 插件。它拥有 stdio,因此必须禁用 stdout 日志;通过工厂创建或恢复 agent(智能体);将 harness 事件映射为协议消息;将请求映射为 `send()` 或 `cancel()`。每个请求从持久的 `turn/end` 恰好结算一次(即使渲染失败),并通过 `AgentHandle.dispose()` 拆除 agent 以使 dispose(资源释放)达到静止状态。 *客户端驱动*是面向协议格式(wire format)对端的 UI 插件。它拥有 stdio,因此必须禁用 stdout 日志;通过工厂创建或恢复 agent(智能体);将 harness 事件映射为协议消息;将请求映射为 `followup()` 或 `cancel()`。每个请求从持久的 `turn/end` 恰好结算一次(即使渲染失败),并通过 `AgentHandle.dispose()` 拆除 agent 以使 dispose(资源释放)达到静止状态。
`packages/ui/acp` 是完整的工作示例:它将 agent 桥接到 ACP(Agent Client Protocol)(基于 stdio 的 JSON-RPC),使 Zed 及其他 ACP 编辑器能够驱动它。其 README 描述了完整的方法接口以及它在审批 seam 上注册的权限提示应答器。 `packages/ui/acp` 是完整的工作示例:它将 agent 桥接到 ACP(Agent Client Protocol)(基于 stdio 的 JSON-RPC),使 Zed 及其他 ACP 编辑器能够驱动它。其 README 描述了完整的方法接口以及它在审批 seam 上注册的权限提示应答器。
@@ -99,9 +99,9 @@ export function apply(ctx: Context) {
|---|---| |---|---|
| 钩子系统(用户级 + 项目级) | `agent/session-start`、`agent/prompt-submit`、`agent/request`、`agent/step-result`、`tools/pre-execute`、`tools/post-execute`、`agent/turn-continuation` 上的监听器——每个拦截 waterfall 返回一个类型化 Decision;`dsh-hooks-claude` / `dsh-hooks-codex` 桥接器将钩子配置文件映射到这些 seam 上 | | 钩子系统(用户级 + 项目级) | `agent/session-start`、`agent/prompt-submit`、`agent/request`、`agent/step-result`、`tools/pre-execute`、`tools/post-execute`、`agent/turn-continuation` 上的监听器——每个拦截 waterfall 返回一个类型化 Decision;`dsh-hooks-claude` / `dsh-hooks-codex` 桥接器将钩子配置文件映射到这些 seam 上 |
| `/goal` | `ctx.goals` 管理持久状态,`dsh-goal-session` 通过公共 `Agent` 调度同会话回合,独立的命令/工具生产方分别提供人类/模型控制 | | `/goal` | `ctx.goals` 管理持久状态,`dsh-goal-session` 通过公共 `Agent` 调度同会话回合,独立的命令/工具生产方分别提供人类/模型控制 |
| `/loop` | 在 `turn/end` 会话事件上 `send()` 下一次迭代;或强制继续 | | `/loop` | 在 `turn/end` 会话事件上 `followup()` 下一次迭代;或强制继续 |
| 动态工作流 | `ctx.workflows` + worker-thread 引擎 + `workflow` 工具;结构化的进程内子任务通过作用域化的 prompt/工具注册、单调工具守卫、最终 `tools/result` 提交(包括外层 `run_code`)和终端 `agent/turn-stop` 来强制输出 | | 动态工作流 | `ctx.workflows` + worker-thread 引擎 + `workflow` 工具;结构化的进程内子任务通过作用域化的 prompt/工具注册、单调工具守卫、最终 `tools/result` 提交(包括外层 `run_code`)和终端 `agent/turn-stop` 来强制输出 |
| 排队消息 + steering(中途引导) | 核心 `Agent.send()` / `Agent.steer()` | | 排队消息 + steering(中途引导) | 核心 `Agent.followup()` / `Agent.steer()` |
| 上下文压缩(context compaction)(自动 + 手动) | `ctx.compact` seam + `dsh-compact-basic`;自动压力检查运行在串行 `agent/post-step`,规范化溢出恢复运行在 `agent/request-error`,手动调用方使用同一个压缩服务([压缩 Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)——面向模型的 `/compact` 消费方工具已推迟) | | 上下文压缩(context compaction)(自动 + 手动) | `ctx.compact` seam + `dsh-compact-basic`;自动压力检查运行在串行 `agent/post-step`,规范化溢出恢复运行在 `agent/request-error`,手动调用方使用同一个压缩服务([压缩 Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)——面向模型的 `/compact` 消费方工具已推迟) |
| 系统提示词可配置性 | `ctx.systemPrompt.section()`,支持排序与作用域局部覆盖 | | 系统提示词可配置性 | `ctx.systemPrompt.section()`,支持排序与作用域局部覆盖 |
| AGENTS.md(根目录) | 一个读取该文件的 section provider | | AGENTS.md(根目录) | 一个读取该文件的 section provider |
@@ -118,8 +118,8 @@ export function apply(ctx: Context) {
| MCP | 每个服务器一个插件:发现工具 → `ctx.tools.register()` | | MCP | 每个服务器一个插件:发现工具 → `ctx.tools.register()` |
| Skill(技能) | section + 工具注册;调用时通过 `inject()` 注入 skill 内容 | | Skill(技能) | section + 工具注册;调用时通过 `inject()` 注入 skill 内容 |
| 记忆 | section provider + 工具 | | 记忆 | section provider + 工具 |
| 定时任务(cron) | 插件注册面向模型的调度工具;定时器触发 → 空闲时 `send(…, {source: {kind: 'cron', …}})`/忙碌时 `inject()` 通知 | | 定时任务(cron) | 插件注册面向模型的调度工具;定时器触发 → 空闲时 `followup(…, {source: {kind: 'cron', …}})`/忙碌时 `inject()` 通知 |
| UI(GUI;CLI 输出 JSONL) | 监听 `session/event`(助手分片、边界、工具活动);输入 → `send()` | | UI(GUI;CLI 输出 JSONL) | 监听 `session/event`(助手分片、边界、工具活动);输入 → `followup()` |
| 遥测 / 可回放 trace | `session/event` → JSONL;回放 = `sessions.create(id, { seed })` | | 遥测 / 可回放 trace | `session/event` → JSONL;回放 = `sessions.create(id, { seed })` |
| 模型适配器 | 通过 `registerAdapter` 注册 `LlmAdapter` 子类(`dsh-llm-deepseek`、`dsh-llm-pi-ai`) | | 模型适配器 | 通过 `registerAdapter` 注册 `LlmAdapter` 子类(`dsh-llm-deepseek`、`dsh-llm-pi-ai`) |
| 插件热重载 | 每个注册都是一个 `ctx.effect` → vendor 的 HMR(热模块替换)直接生效 | | 插件热重载 | 每个注册都是一个 `ctx.effect` → vendor 的 HMR(热模块替换)直接生效 |

View File

@@ -6,7 +6,7 @@ The seam is a textbook [capability seam](../../.agents/notes/implemented/archite
## The flush checkpoint ## The flush checkpoint
`session/event` is a *synchronous* notification; persistence plugins buffer it (write-behind) until `session/flush`. The loop awaits an ordinary turn's checkpoint before claiming the next queue item; synchronous idle `inject()` schedules its checkpoint without blocking `send()`, and disposal still drains it. A successful flush durably commits the closed turn as one unit; a rejecting flush is reported through `agent/error` and the logger — never as a session event past the closed turn — while the backend keeps its buffered events for the next flush. `session/event` is a *synchronous* notification; persistence plugins buffer it (write-behind) until `session/flush`. The loop awaits an ordinary turn's checkpoint before claiming the next queue item; synchronous idle `inject()` schedules its checkpoint without blocking `followup()`, and disposal still drains it. A successful flush durably commits the closed turn as one unit; a rejecting flush is reported through `agent/error` and the logger — never as a session event past the closed turn — while the backend keeps its buffered events for the next flush.
## Crash recovery preserves an interrupted turn ## Crash recovery preserves an interrupted turn

View File

@@ -12,7 +12,7 @@ When an interface documents two valid ways to signal something — an adapter ma
## Async state is not synchronous state ## Async state is not synchronous state
`agent.send()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) instead of treating status as a per-send result: several queued sends run as consecutive turns under one `running` interval, while cancellation or disposal can discard unstarted items. The guard cuts both ways: if the awaited transition can never occur (EOF with no work submitted → never `running`), the wait hangs — handle the "nothing to wait for" branch explicitly. `agent.followup()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) instead of treating status as a per-follow-up result: several queued follow-ups run as consecutive turns under one `running` interval, while cancellation or disposal can discard unstarted items. The guard cuts both ways: if the awaited transition can never occur (EOF with no work submitted → never `running`), the wait hangs — handle the "nothing to wait for" branch explicitly.
## Dispose must reach quiescence, not just request it ## Dispose must reach quiescence, not just request it

View File

@@ -28,9 +28,9 @@
**dispose(资源释放)必须等待所有任务完全停稳,不能仅下发终止指令就返回**:如果清理过程只发出终止或中断信号,却不等任务停止就返回,就会留下孤儿进程。清理应采用异步方式,等待所有子任务彻底退出(先发出终止信号,再等待退出);发出信号前应先关闭监听器与通知注册表,使延迟到达的完成事件不再触发通知。测试要证明 dispose 的确等到清理完成:执行完 `await fiber.dispose()` 后进程 PID 立即消失,不能只检查进程最终会自行消亡。 **dispose(资源释放)必须等待所有任务完全停稳,不能仅下发终止指令就返回**:如果清理过程只发出终止或中断信号,却不等任务停止就返回,就会留下孤儿进程。清理应采用异步方式,等待所有子任务彻底退出(先发出终止信号,再等待退出);发出信号前应先关闭监听器与通知注册表,使延迟到达的完成事件不再触发通知。测试要证明 dispose 的确等到清理完成:执行完 `await fiber.dispose()` 后进程 PID 立即消失,不能只检查进程最终会自行消亡。
> **Async state is not synchronous state** — `agent.send()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) instead of treating status as a per-send result: several queued sends run as consecutive turns under one `running` interval, while cancellation or disposal can discard unstarted items. > **Async state is not synchronous state** — `agent.followup()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) instead of treating status as a per-follow-up result: several queued follow-ups run as consecutive turns under one `running` interval, while cancellation or disposal can discard unstarted items.
**异步状态不等同于同步瞬时状态**:调用 `agent.send()` 不会在返回前同步更新状态;后台任务的完成时间与轮次边界存在竞态;`reader.close()` 既会在读到文件末尾时触发,也会在资源释放时触发。切勿把刚刚发起的状态变更当成已经生效,据此控制流程;生命周期逻辑应以实际触发的事件和已完成的 promise(`agent/status`、`task.done`)为准,并观察完整的状态变化(先 `running`,再 `idle`),不要把状态当作逐次 `send()` 的结果:多次排队的 `send()` 会作为连续轮次运行,但可能共用一个 `running` 区间;取消或资源释放还可能丢弃尚未启动的队列项。 **异步状态不等同于同步瞬时状态**:调用 `agent.followup()` 不会在返回前同步更新状态;后台任务的完成时间与轮次边界存在竞态;`reader.close()` 既会在读到文件末尾时触发,也会在资源释放时触发。切勿把刚刚发起的状态变更当成已经生效,据此控制流程;生命周期逻辑应以实际触发的事件和已完成的 promise(`agent/status`、`task.done`)为准,并观察完整的状态变化(先 `running`,再 `idle`),不要把状态当作逐次 `followup()` 的结果:多次排队的 `followup()` 会作为连续轮次运行,但可能共用一个 `running` 区间;取消或资源释放还可能丢弃尚未启动的队列项。
## ③ 测试政策清单 ## ③ 测试政策清单

View File

@@ -42,7 +42,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif
const log = vi.spyOn(console, 'log').mockImplementation(() => {}) const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const agent = ctx.agentLoop.create(SessionId('cordis-e2e-listener'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) const agent = ctx.agentLoop.create(SessionId('cordis-e2e-listener'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
agent.send([{ agent.followup([{
type: 'text', type: 'text',
text: 'Use cordis_mount to mount a plugin that listens to the \'agent/status\' ' text: 'Use cordis_mount to mount a plugin that listens to the \'agent/status\' '
+ 'cordis event and logs every change with console.log. Reply "mounted" once done.', + 'cordis event and logs every change with console.log. Reply "mounted" once done.',
@@ -58,7 +58,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif
}) })
expect(resultText(mid)).toContain('dyn-') expect(resultText(mid)).toContain('dyn-')
agent.send([{ type: 'text', text: 'Now unmount the plugin you just mounted.' }]) agent.followup([{ type: 'text', text: 'Now unmount the plugin you just mounted.' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
const after = await ctx.tools.execute({ const after = await ctx.tools.execute({
@@ -72,7 +72,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif
ctx = await cordisHarness() ctx = await cordisHarness()
const agent = ctx.agentLoop.create(SessionId('cordis-e2e-selftool'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) const agent = ctx.agentLoop.create(SessionId('cordis-e2e-selftool'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
agent.send([{ agent.followup([{
type: 'text', type: 'text',
text: 'Give yourself a new tool: use cordis_mount to mount a plugin with ' text: 'Give yourself a new tool: use cordis_mount to mount a plugin with '
+ 'inject ["tools"] that calls harness.registerTool(ctx, harness.defineTool({...})) ' + 'inject ["tools"] that calls harness.registerTool(ctx, harness.defineTool({...})) '
@@ -119,7 +119,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif
ctx = await cordisHarness() ctx = await cordisHarness()
const agent = ctx.agentLoop.create(SessionId('cordis-e2e-compose'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) const agent = ctx.agentLoop.create(SessionId('cordis-e2e-compose'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
agent.send([{ agent.followup([{
type: 'text', type: 'text',
text: 'Mount TWO separate plugins with cordis_mount. First a provider: apply calls ' text: 'Mount TWO separate plugins with cordis_mount. First a provider: apply calls '
+ 'ctx.provide(\'shouter\', { shout: (s) => s.toUpperCase() }). Second a consumer with ' + 'ctx.provide(\'shouter\', { shout: (s) => s.toUpperCase() }). Second a consumer with '
@@ -144,7 +144,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif
.flatMap(event => event.data.content.filter(block => block.type === 'text').map(block => block.text)) .flatMap(event => event.data.content.filter(block => block.type === 'text').map(block => block.text))
expect(shoutResults.some(text => text.includes('QUIET'))).toBe(true) expect(shoutResults.some(text => text.includes('QUIET'))).toBe(true)
agent.send([{ type: 'text', text: 'Now unmount ONLY the provider plugin (the one that provided shouter).' }]) agent.followup([{ type: 'text', text: 'Now unmount ONLY the provider plugin (the one that provided shouter).' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
// The consumer must have been parked by cordis itself: service gone, // The consumer must have been parked by cordis itself: service gone,

View File

@@ -311,7 +311,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p
ctx = await codeModeHarness(workdir) ctx = await codeModeHarness(workdir)
const agent = ctx.agentLoop.create(SessionId('e2e-code-mode'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) const agent = ctx.agentLoop.create(SessionId('e2e-code-mode'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
agent.send([{ agent.followup([{
type: 'text', type: 'text',
text: 'Using one run_code program: run `echo alpha-7` with the bash tool, run `echo beta-9` with the bash tool, ' text: 'Using one run_code program: run `echo alpha-7` with the bash tool, run `echo beta-9` with the bash tool, '
+ 'then write both outputs joined by a plus sign into combined.txt (bash heredoc or redirect), ' + 'then write both outputs joined by a plus sign into combined.txt (bash heredoc or redirect), '
@@ -363,7 +363,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p
agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' },
}) })
handle.agent.send([{ handle.agent.followup([{
type: 'text', type: 'text',
text: 'Use one run_code program to call tools.read on pkg/deep/task.txt. After it finishes, answer: Code Mode workspace handshake?', text: 'Use one run_code program to call tools.read on pkg/deep/task.txt. After it finishes, answer: Code Mode workspace handshake?',
}]) }])

View File

@@ -56,7 +56,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('coding task: fix a failing test
ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT }) ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT })
const agent = ctx.agentLoop.create(SessionId('e2e-task'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) const agent = ctx.agentLoop.create(SessionId('e2e-task'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
agent.send([{ agent.followup([{
type: 'text', type: 'text',
text: 'In the current directory, `node add.test.js` fails because add.js has a bug. ' text: 'In the current directory, `node add.test.js` fails because add.js has a bug. '
+ 'Fix add.js so the test passes, run `node add.test.js` to verify, and report the result. ' + 'Fix add.js so the test passes, run `node add.test.js` to verify, and report the result. '

View File

@@ -46,7 +46,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa
}) })
const agent = ctx.agentLoop.create(SessionId('e2e-compaction'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) const agent = ctx.agentLoop.create(SessionId('e2e-compaction'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
agent.send([{ agent.followup([{
type: 'text', type: 'text',
text: 'Read file1.txt, file2.txt, file3.txt, and file4.txt one at a ' text: 'Read file1.txt, file2.txt, file3.txt, and file4.txt one at a '
+ 'time using cat (a separate bash command for each). After reading all four, tell me how ' + 'time using cat (a separate bash command for each). After reading all four, tell me how '

View File

@@ -30,7 +30,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('full loop: real model + real bas
ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT }) ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT })
const agent = ctx.agentLoop.create(SessionId('e2e-loop'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) const agent = ctx.agentLoop.create(SessionId('e2e-loop'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
agent.send([{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }]) agent.followup([{ 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)
const events = [...agent.session.events] const events = [...agent.session.events]

View File

@@ -41,7 +41,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses
sessionId: SESSION_ID, sessionId: SESSION_ID,
agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' },
})).agent })).agent
first.send([{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }]) first.followup([{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }])
await waitForIdle(ctx, first) await waitForIdle(ctx, first)
await ctx.fiber.dispose() await ctx.fiber.dispose()
ctx = undefined ctx = undefined
@@ -58,7 +58,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses
// The prior user turn is in the rehydrated log before the model is asked. // The prior user turn is in the rehydrated log before the model is asked.
expect(JSON.stringify(resumed.session.deriveMessages())).toContain(SECRET) expect(JSON.stringify(resumed.session.deriveMessages())).toContain(SECRET)
resumed.send([{ type: 'text', text: 'What was the code I asked you to remember? Reply with just the code.' }]) resumed.followup([{ type: 'text', text: 'What was the code I asked you to remember? Reply with just the code.' }])
await waitForIdle(ctx, resumed) await waitForIdle(ctx, resumed)
// The model recalls it — only possible from the resumed history. // The model recalls it — only possible from the resumed history.

View File

@@ -28,7 +28,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('todo_write: real model records a
ctx = await codingHarness(workdir, { persona: TODO_SYSTEM_PROMPT }) ctx = await codingHarness(workdir, { persona: TODO_SYSTEM_PROMPT })
const agent = ctx.agentLoop.create(SessionId('e2e-todo'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) const agent = ctx.agentLoop.create(SessionId('e2e-todo'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
agent.send([{ type: 'text', text: agent.followup([{ 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 '
+ '"inspect the failing test" (in_progress), then "apply the fix" (pending). ' + '"inspect the failing test" (in_progress), then "apply the fix" (pending). '
+ 'Send both in one todo_write call, then reply with the single word DONE.' }]) + 'Send both in one todo_write call, then reply with the single word DONE.' }])

View File

@@ -109,7 +109,7 @@ describe('bash tool through the agent loop', () => {
const location = ctx.sessionPersistence.locate(agent.session.header) const location = ctx.sessionPersistence.locate(agent.session.header)
expect(location?.kind).toBe('jsonl') expect(location?.kind).toBe('jsonl')
agent.send([{ type: 'text', text: 'inspect the current session' }]) agent.followup([{ type: 'text', text: 'inspect the current session' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
const result = findEvent(events(agent), 'tool/result') const result = findEvent(events(agent), 'tool/result')
@@ -128,7 +128,7 @@ describe('bash tool through the agent loop', () => {
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-fg'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('it-fg'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'run echo integration-ok' }]) agent.followup([{ type: 'text', text: 'run echo integration-ok' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
const log = events(agent) const log = events(agent)
@@ -160,7 +160,7 @@ describe('bash tool through the agent loop', () => {
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-exit'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('it-exit'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'run exit 9' }]) agent.followup([{ type: 'text', text: 'run exit 9' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
const toolResult = findEvent(events(agent), 'tool/result') const toolResult = findEvent(events(agent), 'tool/result')
@@ -180,7 +180,7 @@ describe('bash tool through the agent loop', () => {
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-bg'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('it-bg'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'run echo bg-ok in the background' }]) agent.followup([{ type: 'text', text: 'run echo bg-ok in the background' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
const firstResult = findEvent(events(agent), 'tool/result') const firstResult = findEvent(events(agent), 'tool/result')
@@ -200,7 +200,7 @@ describe('bash tool through the agent loop', () => {
expect(notice.data.source).toEqual({ kind: 'plugin', plugin: 'tool-tasks' }) expect(notice.data.source).toEqual({ kind: 'plugin', plugin: 'tool-tasks' })
// The next turn collects the output through the generic task tool. // The next turn collects the output through the generic task tool.
agent.send([{ type: 'text', text: 'collect it' }]) agent.followup([{ type: 'text', text: 'collect it' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
const readResult = findEvent(events(agent), 'tool/result', 'last') const readResult = findEvent(events(agent), 'tool/result', 'last')
expect(readResult.data.isError).toBe(false) expect(readResult.data.isError).toBe(false)

View File

@@ -197,7 +197,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
provider: 'unconfigured-agent-fallback', provider: 'unconfigured-agent-fallback',
model: 'unconfigured-agent-fallback', model: 'unconfigured-agent-fallback',
}) })
agent.send([{ type: 'text', text: 'do a routed multi-step task' }]) agent.followup([{ type: 'text', text: 'do a routed multi-step task' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(agent.session.requestHeader()?.config.model).toBe('mock') expect(agent.session.requestHeader()?.config.model).toBe('mock')
@@ -215,7 +215,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
const { ctx } = await harness(8) const { ctx } = await harness(8)
try { try {
const agent = ctx.agentLoop.create(SessionId('post-step-order'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('post-step-order'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'do tool work' }]) agent.followup([{ type: 'text', text: 'do tool work' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
const events = [...agent.session.events] const events = [...agent.session.events]
@@ -241,7 +241,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
const { ctx } = await harness(8) const { ctx } = await harness(8)
try { try {
const agent = ctx.agentLoop.create(SessionId('repro'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('repro'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'do a long multi-step task' }]) agent.followup([{ type: 'text', text: 'do a long multi-step task' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
const events = [...agent.session.events] const events = [...agent.session.events]
@@ -297,7 +297,7 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
}) })
seedOverflowHistory(agent) seedOverflowHistory(agent)
agent.send([{ type: 'text', text: 'continue from history' }]) agent.followup([{ type: 'text', text: 'continue from history' }])
await agent.whenIdle() await agent.whenIdle()
expect(adapter.conversationRequests).toHaveLength(2) expect(adapter.conversationRequests).toHaveLength(2)
@@ -360,7 +360,7 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
try { try {
const agent = ctx.agentLoop.create(SessionId('alternating-recovery'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('alternating-recovery'), { provider: 'mock', model: 'mock' })
seedOverflowHistory(agent) seedOverflowHistory(agent)
agent.send([{ type: 'text', text: 'continue from history' }]) agent.followup([{ type: 'text', text: 'continue from history' }])
await agent.whenIdle() await agent.whenIdle()
expect(adapter.conversationRequests).toHaveLength(3) expect(adapter.conversationRequests).toHaveLength(3)

View File

@@ -42,8 +42,8 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
session, session,
status: 'running', status: 'running',
ctx: new Context(), ctx: new Context(),
send: () => AgentMessageId('stub'),
followup: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'),
queue: () => AgentMessageId('stub'),
steer: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'),
inject(content, options) { inject(content, options) {
session.append('user/message', { session.append('user/message', {
@@ -52,6 +52,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
}, { surfaceOp: 'append' }) }, { surfaceOp: 'append' })
return AgentMessageId('stub') return AgentMessageId('stub')
}, },
send: () => AgentMessageId('stub'),
cancel() {}, cancel() {},
whenIdle: () => Promise.resolve(), whenIdle: () => Promise.resolve(),
} }
@@ -373,7 +374,7 @@ describe('real agent-loop request history', () => {
}) })
const agent = ctx.agentLoop.create(SessionId(`late-${mode}`), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId(`late-${mode}`), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'start' }]) agent.followup([{ type: 'text', text: 'start' }])
await agent.whenIdle() await agent.whenIdle()
expect(laterSawReading).toBe(true) expect(laterSawReading).toBe(true)
@@ -399,7 +400,7 @@ describe('real agent-loop request history', () => {
})) }))
const agent = ctx.agentLoop.create(SessionId('loop'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('loop'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'start' }]) agent.followup([{ type: 'text', text: 'start' }])
await agent.whenIdle() await agent.whenIdle()
expect(adapter.requests).toHaveLength(2) expect(adapter.requests).toHaveLength(2)

View File

@@ -78,7 +78,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode
it('obeys a probe instruction loaded from the workspace', async () => { it('obeys a probe instruction loaded from the workspace', async () => {
const live = await harness() const live = await harness()
live.agent.send([{ type: 'text', text: 'Workspace context handshake?' }]) live.agent.followup([{ type: 'text', text: 'Workspace context handshake?' }])
await waitForIdle(live.ctx, live.agent) await waitForIdle(live.ctx, live.agent)
expect(finalText([...live.agent.session.events])).toContain(PROBE) expect(finalText([...live.agent.session.events])).toContain(PROBE)
@@ -90,7 +90,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode
await writeFile(join(workdir!, 'pkg/AGENTS.md'), `If the user asks for the nested instruction handshake, reply with exactly this string and nothing else: ${NESTED_PROBE}.\n`) await writeFile(join(workdir!, 'pkg/AGENTS.md'), `If the user asks for the nested instruction handshake, reply with exactly this string and nothing else: ${NESTED_PROBE}.\n`)
await writeFile(join(workdir!, 'pkg/deep/file.txt'), 'This file exists only to trigger nested workspace instructions.\n') await writeFile(join(workdir!, 'pkg/deep/file.txt'), 'This file exists only to trigger nested workspace instructions.\n')
live.agent.send([{ type: 'text', text: 'Use the read tool to inspect pkg/deep/file.txt. After reading it, answer: nested instruction handshake?' }]) live.agent.followup([{ type: 'text', text: 'Use the read tool to inspect pkg/deep/file.txt. After reading it, answer: nested instruction handshake?' }])
await waitForIdle(live.ctx, live.agent) await waitForIdle(live.ctx, live.agent)
expect(finalText([...live.agent.session.events])).toContain(NESTED_PROBE) expect(finalText([...live.agent.session.events])).toContain(NESTED_PROBE)
@@ -99,11 +99,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode
it('appends changed baseline instructions after a real file-tool touch without rewriting the frozen prefix', async () => { it('appends changed baseline instructions after a real file-tool touch without rewriting the frozen prefix', async () => {
const live = await harness() const live = await harness()
await writeFile(join(workdir!, 'trigger.txt'), 'This file triggers workspace instruction reconciliation.\n') await writeFile(join(workdir!, 'trigger.txt'), 'This file triggers workspace instruction reconciliation.\n')
live.agent.send([{ type: 'text', text: 'Workspace context handshake?' }]) live.agent.followup([{ type: 'text', text: 'Workspace context handshake?' }])
await waitForIdle(live.ctx, live.agent) await waitForIdle(live.ctx, live.agent)
await writeFile(join(workdir!, 'AGENTS.md'), `The old workspace handshake no longer applies. If the user asks for the updated workspace context handshake, reply with exactly this string and nothing else: ${UPDATED_PROBE}.\n`) await writeFile(join(workdir!, 'AGENTS.md'), `The old workspace handshake no longer applies. If the user asks for the updated workspace context handshake, reply with exactly this string and nothing else: ${UPDATED_PROBE}.\n`)
live.agent.send([{ type: 'text', text: 'You must use the read tool to inspect trigger.txt. After reading it, answer: updated workspace context handshake?' }]) live.agent.followup([{ type: 'text', text: 'You must use the read tool to inspect trigger.txt. After reading it, answer: updated workspace context handshake?' }])
await waitForIdle(live.ctx, live.agent) await waitForIdle(live.ctx, live.agent)
const events = [...live.agent.session.events] const events = [...live.agent.session.events]

View File

@@ -177,8 +177,8 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
options: {}, options: {},
session, session,
status: 'idle', status: 'idle',
send: () => AgentMessageId('stub'),
followup: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'),
queue: () => AgentMessageId('stub'),
steer: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'),
inject(content, options) { inject(content, options) {
session.append('user/message', { session.append('user/message', {
@@ -187,6 +187,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
}, { surfaceOp: 'append' }) }, { surfaceOp: 'append' })
return AgentMessageId('stub') return AgentMessageId('stub')
}, },
send: () => AgentMessageId('stub'),
cancel() {}, cancel() {},
whenIdle: () => Promise.resolve(), whenIdle: () => Promise.resolve(),
} }
@@ -1712,11 +1713,11 @@ describe('dynamic nested workspace context injection', () => {
}, },
})) }))
agent.send([{ type: 'text', text: 'read and abort' }]) agent.followup([{ type: 'text', text: 'read and abort' }])
await agent.whenIdle() await agent.whenIdle()
expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(1) expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(1)
agent.send([{ type: 'text', text: 'retry the read' }]) agent.followup([{ type: 'text', text: 'retry the read' }])
await agent.whenIdle() await agent.whenIdle()
const contexts = agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') const contexts = agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')

View File

@@ -61,6 +61,8 @@ describe('cordis_inspect', () => {
// generated TYPE_API — a consumer can see field types, not just names). // generated TYPE_API — a consumer can see field types, not just names).
expect(report).toContain('type shapes (referenced by the signatures above') expect(report).toContain('type shapes (referenced by the signatures above')
expect(report).toContain('export interface ToolExecution') expect(report).toContain('export interface ToolExecution')
expect(report).toContain('export class Session')
expect(report).toContain('export interface SessionSurface')
// A type only reachable through a NOT-live service (e.g. bash) is scoped out. // A type only reachable through a NOT-live service (e.g. bash) is scoped out.
expect(report).not.toContain('export interface BashRunResult') expect(report).not.toContain('export interface BashRunResult')
// The inherited ctx surface closes the section. // The inherited ctx surface closes the section.

View File

@@ -47,7 +47,7 @@ describe('cordis tools through the agent loop', () => {
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-cordis'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('it-cordis'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'give yourself reverse_text, use it, clean up' }]) agent.followup([{ type: 'text', text: 'give yourself reverse_text, use it, clean up' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
const log = agent.session.events const log = agent.session.events

View File

@@ -41,7 +41,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
} }
function send(agent: Agent, text: string): void { function send(agent: Agent, text: string): void {
agent.send([{ type: 'text', text }]) agent.followup([{ type: 'text', text }])
} }
/** Adapter that holds both drivers at the same awaited continuation. */ /** Adapter that holds both drivers at the same awaited continuation. */

View File

@@ -55,7 +55,7 @@ describe('inbox FIFO-conservation invariant', () => {
return { action: 'continue' as const, reason: { content: [{ type: 'text', text: 'keep going' }], source: { kind: 'plugin', plugin: 'loop' } } } return { action: 'continue' as const, reason: { content: [{ type: 'text', text: 'keep going' }], source: { kind: 'plugin', plugin: 'loop' } } }
}) })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(2) expect(adapter.requests).toHaveLength(2)
@@ -80,7 +80,7 @@ describe('inbox FIFO-conservation invariant', () => {
return { action: 'continue' as const, reason: { content: [{ type: 'text', text: 'keep going' }], source: { kind: 'plugin', plugin: 'loop' } } } return { action: 'continue' as const, reason: { content: [{ type: 'text', text: 'keep going' }], source: { kind: 'plugin', plugin: 'loop' } } }
}) })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false) expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false)
@@ -110,7 +110,7 @@ describe('inbox FIFO-conservation invariant', () => {
return { action: 'stop' as const } return { action: 'stop' as const }
}) })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(discards).toEqual([1]) // the dropped steering item was reported expect(discards).toEqual([1]) // the dropped steering item was reported
@@ -142,7 +142,7 @@ describe('inbox FIFO-conservation invariant', () => {
agent.steer([{ type: 'text', text: 'late' }], { source: { kind: 'plugin', plugin: 'late' } }) agent.steer([{ type: 'text', text: 'late' }], { source: { kind: 'plugin', plugin: 'late' } })
}) })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
// The prompt plus the late steer both enqueued; both are matched (the prompt // The prompt plus the late steer both enqueued; both are matched (the prompt

View File

@@ -115,7 +115,7 @@ describe('agent loop scheduling properties', () => {
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.
for (const text of texts) agent.send([{ type: 'text', text }]) for (const text of texts) agent.followup([{ type: 'text', text }])
await idle await idle
// No message lost: every send appears as a user/message, in order. // No message lost: every send appears as a user/message, in order.
@@ -142,7 +142,7 @@ describe('agent loop scheduling properties', () => {
const agent = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', 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.followup([{ type: 'text', text }])
await idle await idle
} }
// Each send was drained at a separate turn start: N turns, 1..N. // Each send was drained at a separate turn start: N turns, 1..N.
@@ -171,7 +171,7 @@ describe('agent loop scheduling properties', () => {
for (const step of steps) { for (const step of steps) {
const idle = nextIdle(ctx, agent) const idle = nextIdle(ctx, agent)
lastIdle = idle lastIdle = idle
agent.send([{ type: 'text', text: step.text }]) agent.followup([{ type: 'text', text: step.text }])
if (step.settle) await idle if (step.settle) await idle
} }
await lastIdle await lastIdle

View File

@@ -73,10 +73,10 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('log-derived request cache hits (
const agent = ctx.agentLoop.create(SessionId('cache-e2e'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) const agent = ctx.agentLoop.create(SessionId('cache-e2e'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
// Turn 1: forces a tool call → at least two steps (two model requests). // 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.followup([{ type: 'text', text: 'Look up the key "deploy-color" with the lookup tool and tell me the value.' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
// Turn 2: a follow-up over the same (longer) prefix. // Turn 2: a follow-up over the same (longer) prefix.
agent.send([{ type: 'text', text: 'Thanks. Repeat that value one more time.' }]) agent.followup([{ type: 'text', text: 'Thanks. Repeat that value one more time.' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
const usages = [...agent.session.events] const usages = [...agent.session.events]

View File

@@ -41,7 +41,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
} }
function send(agent: Agent, text: string) { function send(agent: Agent, text: string) {
agent.send([{ type: 'text', text }]) agent.followup([{ type: 'text', text }])
} }
/** Assert `previous` is a strict value-prefix of `current`. */ /** Assert `previous` is a strict value-prefix of `current`. */

View File

@@ -114,7 +114,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
} }
function send(agent: Agent): void { function send(agent: Agent): void {
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
} }
function contextError(message = 'context too large'): LlmError { function contextError(message = 'context too large'): LlmError {

View File

@@ -203,11 +203,11 @@ describe('agent scope lifecycle', () => {
if (event.type === 'user/message') heard.push('a-sees:user-message') if (event.type === 'user/message') heard.push('a-sees:user-message')
}) })
b.send(text('for b')) b.followup(text('for b'))
await waitForIdle(ctx, b) await waitForIdle(ctx, b)
expect(heard).toEqual([]) // nothing of b's leaked into a's scope expect(heard).toEqual([]) // nothing of b's leaked into a's scope
a.send(text('for a')) a.followup(text('for a'))
await waitForIdle(ctx, a) await waitForIdle(ctx, a)
expect(heard).toContain('a-sees:a:running') expect(heard).toContain('a-sees:a:running')
expect(heard).toContain('a-sees:user-message') expect(heard).toContain('a-sees:user-message')
@@ -934,7 +934,7 @@ describe('agent scope lifecycle', () => {
if (event.type === 'turn/start') { off(); resolve() } if (event.type === 'turn/start') { off(); resolve() }
}) })
}) })
agent.send(text('work')) agent.followup(text('work'))
await turnOpen await turnOpen
await owner.dispose() await owner.dispose()
expect(order).toEqual(['turn-end', 'disposed(listed=false)', 'session-still-stored=true']) expect(order).toEqual(['turn-end', 'disposed(listed=false)', 'session-still-stored=true'])

View File

@@ -105,7 +105,7 @@ describe('tool-call scheduler: grouping and barriers', () => {
ctx.tools.register(gated.tool) ctx.tools.register(gated.tool)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 3) await until(() => gated.started.length === 3)
expect(gated.started).toEqual(['1', '2', '3']) expect(gated.started).toEqual(['1', '2', '3'])
gated.release('1'); gated.release('2'); gated.release('3') gated.release('1'); gated.release('2'); gated.release('3')
@@ -133,7 +133,7 @@ describe('tool-call scheduler: grouping and barriers', () => {
async execute(args) { order.push(`w-${args.id}`); return [{ type: 'text', text: 'w' }] }, async execute(args) { order.push(`w-${args.id}`); return [{ type: 'text', text: 'w' }] },
})) }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(order).toEqual(['r-start-A1', 'r-end-A1', 'w-A2', 'r-start-A3', 'r-end-A3']) expect(order).toEqual(['r-start-A1', 'r-end-A1', 'w-A2', 'r-start-A3', 'r-end-A3'])
@@ -169,7 +169,7 @@ describe('tool-call scheduler: grouping and barriers', () => {
})) }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await until(() => replacement.started.length === 1) await until(() => replacement.started.length === 1)
await new Promise(r => setTimeout(r, 5)) await new Promise(r => setTimeout(r, 5))
expect(replacement.started).toEqual(['1']) expect(replacement.started).toEqual(['1'])
@@ -200,7 +200,7 @@ describe('tool-call scheduler: grouping and barriers', () => {
}) })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await until(() => initial.started.length === 2) await until(() => initial.started.length === 2)
initial.release('1') initial.release('1')
await until(() => events(agent).some(event => await until(() => events(agent).some(event =>
@@ -226,7 +226,7 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme
ctx.tools.register(gated.tool) ctx.tools.register(gated.tool)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 2) await until(() => gated.started.length === 2)
gated.release('2') gated.release('2')
await new Promise(r => setTimeout(r, 5)) await new Promise(r => setTimeout(r, 5))
@@ -248,7 +248,7 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme
const gated = gatedParallelTool('p') const gated = gatedParallelTool('p')
ctx.tools.register(gated.tool) ctx.tools.register(gated.tool)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 2) await until(() => gated.started.length === 2)
gated.release('2'); gated.release('1') gated.release('2'); gated.release('1')
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
@@ -295,7 +295,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
ctx.tools.register(gated.tool) ctx.tools.register(gated.tool)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 2) await until(() => gated.started.length === 2)
await new Promise(r => setTimeout(r, 5)) await new Promise(r => setTimeout(r, 5))
expect(gated.started).toEqual(['1', '2']) expect(gated.started).toEqual(['1', '2'])
@@ -324,7 +324,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
const gated = gatedParallelTool('p') const gated = gatedParallelTool('p')
ctx.tools.register(gated.tool) ctx.tools.register(gated.tool)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 1) await until(() => gated.started.length === 1)
await new Promise(r => setTimeout(r, 5)) await new Promise(r => setTimeout(r, 5))
expect(gated.started).toEqual(['1']) expect(gated.started).toEqual(['1'])
@@ -350,7 +350,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
const gated = gatedParallelTool('p') const gated = gatedParallelTool('p')
ctx.tools.register(gated.tool) ctx.tools.register(gated.tool)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 1) await until(() => gated.started.length === 1)
await new Promise(r => setTimeout(r, 5)) await new Promise(r => setTimeout(r, 5))
expect(gated.started).toEqual(['1']) expect(gated.started).toEqual(['1'])
@@ -377,7 +377,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => { post.push(String(exec.callId)); return next() }) ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => { post.push(String(exec.callId)); return next() })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 3) await until(() => gated.started.length === 3)
gated.release('3'); gated.release('2'); gated.release('1') gated.release('3'); gated.release('2'); gated.release('1')
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
@@ -398,7 +398,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
({ kind: 'accept', additionalContexts: [{ content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' } }] })) ({ kind: 'accept', additionalContexts: [{ content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' } }] }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 2) await until(() => gated.started.length === 2)
gated.release('2'); gated.release('1') gated.release('2'); gated.release('1')
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
@@ -436,7 +436,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
}) })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 1) await until(() => gated.started.length === 1)
gated.release('1') gated.release('1')
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
@@ -466,7 +466,7 @@ describe('tool-call scheduler: abort handling', () => {
} }
}) })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(gated.started).toEqual([]) expect(gated.started).toEqual([])
@@ -498,7 +498,7 @@ describe('tool-call scheduler: abort handling', () => {
return next() return next()
}) })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(gated.started).toEqual([]) expect(gated.started).toEqual([])
@@ -528,7 +528,7 @@ describe('tool-call scheduler: abort handling', () => {
})) }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 2) await until(() => gated.started.length === 2)
agent.cancel({ kind: 'user' }) agent.cancel({ kind: 'user' })
gated.release('1') gated.release('1')
@@ -575,7 +575,7 @@ describe('tool-call scheduler: abort handling', () => {
})) }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 2) await until(() => gated.started.length === 2)
agent.cancel({ kind: 'user' }) agent.cancel({ kind: 'user' })
gated.release('1') gated.release('1')

View File

@@ -58,7 +58,7 @@ async function runTurn(registrationOrder: string[], toolOrder?: SystemPromptConf
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(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
return { ctx, agent, adapter } return { ctx, agent, adapter }
} }
@@ -100,7 +100,7 @@ describe('loop-level canonical tool order', () => {
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(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0) expect(adapter.requests).toHaveLength(0)
expect(errors.map(e => e.message)).toEqual(['toolOrder lists unregistered tool "ghost"; known tools: alpha']) expect(errors.map(e => e.message)).toEqual(['toolOrder lists unregistered tool "ghost"; known tools: alpha'])

View File

@@ -34,7 +34,7 @@ async function harness(adapter: MockAdapter): Promise<Context> {
} }
function send(agent: Agent, text = 'go'): Promise<void> { function send(agent: Agent, text = 'go'): Promise<void> {
agent.send([{ type: 'text', text }]) agent.followup([{ type: 'text', text }])
return agent.whenIdle() return agent.whenIdle()
} }
@@ -116,7 +116,7 @@ describe('agent/turn-stop', () => {
ctx.on('session/flush', (session) => { ctx.on('session/flush', (session) => {
if (session !== agent.session || queued) return if (session !== agent.session || queued) return
queued = true queued = true
agent.send([{ type: 'text', text: 'ordinary queued follow-up' }]) agent.followup([{ type: 'text', text: 'ordinary queued follow-up' }])
}) })
await send(agent) await send(agent)

View File

@@ -235,7 +235,7 @@ describe('dsh-agent-spine-demo bundle', () => {
agentOptions: { provider: 'mock', model: 'mock' }, agentOptions: { provider: 'mock', model: 'mock' },
}) })
handle.agent.send([{ type: 'text', text: 'recover' }]) handle.agent.followup([{ type: 'text', text: 'recover' }])
await waitForIdle(ctx, handle.agent) await waitForIdle(ctx, handle.agent)
expect(adapter.requests).toBe(2) expect(adapter.requests).toBe(2)
@@ -335,7 +335,7 @@ describe('dsh-agent-spine-demo bundle', () => {
}) })
const agent = handle.agent const agent = handle.agent
agent.send([{ type: 'text', text: 'hi' }]) agent.followup([{ type: 'text', text: 'hi' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
const sentText = adapter.requests[0]?.messages.map(messageText).join('\n') const sentText = adapter.requests[0]?.messages.map(messageText).join('\n')
@@ -364,7 +364,7 @@ describe('dsh-agent-spine-demo bundle', () => {
agentOptions: { provider: 'mock', model: 'mock' }, agentOptions: { provider: 'mock', model: 'mock' },
}) })
handle.agent.send([{ type: 'text', text: 'hi' }]) handle.agent.followup([{ type: 'text', text: 'hi' }])
await waitForIdle(ctx, handle.agent) await waitForIdle(ctx, handle.agent)
expect(adapter.requests[0]?.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'hi' }] }]) expect(adapter.requests[0]?.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'hi' }] }])
@@ -454,7 +454,7 @@ describe('dsh-agent-spine-demo bundle', () => {
agentOptions: { provider: 'mock', model: 'mock' }, agentOptions: { provider: 'mock', model: 'mock' },
}) })
handle.agent.send([{ type: 'text', text: 'hi' }]) handle.agent.followup([{ type: 'text', text: 'hi' }])
await waitForIdle(ctx, handle.agent) await waitForIdle(ctx, handle.agent)
expect(messageText(adapter.requests[0]?.messages[0])).toContain('workspace rule before skills') expect(messageText(adapter.requests[0]?.messages[0])).toContain('workspace rule before skills')

View File

@@ -290,7 +290,7 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise
try { try {
/* v8 ignore next -- skips send only when cancellation wins the listener-registration race above */ /* v8 ignore next -- skips send only when cancellation wins the listener-registration race above */
if (!settled) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition if (!settled) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
agent.send([{ type: 'text', text: options.task }]) agent.followup([{ type: 'text', text: options.task }])
} }
await turnEnded await turnEnded
} finally { } finally {

View File

@@ -471,7 +471,7 @@ describe('runOneShot and executeCli', () => {
startup.ctx.on('session/event', (session, event) => { startup.ctx.on('session/event', (session, event) => {
if (session === startup.agent.session && event.type === 'assistant/chunk') started() if (session === startup.agent.session && event.type === 'assistant/chunk') started()
}) })
startup.agent.send([{ type: 'text', text: 'first' }]) startup.agent.followup([{ type: 'text', text: 'first' }])
await running await running
const startupAbort = new AbortController() const startupAbort = new AbortController()
const waiting = runOneShot(startup.ctx, { task: 'second', signal: startupAbort.signal }) const waiting = runOneShot(startup.ctx, { task: 'second', signal: startupAbort.signal })

View File

@@ -36,7 +36,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () =>
// (config.cwd = workdir) is the workspace. // (config.cwd = workdir) is the workspace.
const agent = ctx.agentLoop.create(SessionId('fs-e2e'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) const agent = ctx.agentLoop.create(SessionId('fs-e2e'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
agent.send([{ type: 'text', text: agent.followup([{ 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. '
+ 'Then read it back, then edit it to replace the literal word draft with final. ' + 'Then read it back, then edit it to replace the literal word draft with final. '
+ 'Tell me when done.' }]) + 'Tell me when done.' }])
@@ -68,7 +68,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () =>
meta: { cwd: sessionDir }, meta: { cwd: sessionDir },
agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' },
}) })
handle.agent.send([{ type: 'text', text: handle.agent.followup([{ type: 'text', text:
'Use the write tool to create a file named where.txt containing exactly the line: here. Tell me when done.' }]) 'Use the write tool to create a file named where.txt containing exactly the line: here. Tell me when done.' }])
await waitForIdle(ctx, handle.agent) await waitForIdle(ctx, handle.agent)

View File

@@ -7,7 +7,7 @@ import type { GoalView } from '@deepseek-ai/dsh-goal'
* Render the complete goal-round instruction retained in session history. * Render the complete goal-round instruction retained in session history.
* @param goal - exact active goal revision being admitted. * @param goal - exact active goal revision being admitted.
* @param round - next positive round number. * @param round - next positive round number.
* @returns a fresh one-block prompt for `Agent.send()`. * @returns a fresh one-block prompt for `Agent.followup()`.
*/ */
export function renderGoalRoundPrompt(goal: GoalView, round: number): ContentBlock[] { export function renderGoalRoundPrompt(goal: GoalView, round: number): ContentBlock[] {
return [{ return [{

View File

@@ -276,7 +276,7 @@ describe('same-session goal driving', () => {
? Promise.resolve({ kind: 'block', reason: 'stop this round' }) ? Promise.resolve({ kind: 'block', reason: 'stop this round' })
: next()) : next())
test.ctx.on('goal/changed', (agent, change) => { test.ctx.on('goal/changed', (agent, change) => {
if (change.operation === 'block') agent.send([{ type: 'text', text: 'inspect the blocker' }]) if (change.operation === 'block') agent.followup([{ type: 'text', text: 'inspect the blocker' }])
}) })
test.ctx.goals.create(test.agent, { objective: 'stop and inspect' }) test.ctx.goals.create(test.agent, { objective: 'stop and inspect' })
@@ -323,7 +323,7 @@ describe('same-session goal driving', () => {
it('lets already-queued human work finish before reserving the next round', async () => { it('lets already-queued human work finish before reserving the next round', async () => {
const test = await harness([textResponse('human answer'), textResponse('goal answer')]) const test = await harness([textResponse('human answer'), textResponse('goal answer')])
test.ctx.goals.create(test.agent, { objective: 'continue after the human', maxGoalRounds: 1 }) test.ctx.goals.create(test.agent, { objective: 'continue after the human', maxGoalRounds: 1 })
test.agent.send([{ type: 'text', text: 'human goes first' }]) test.agent.followup([{ type: 'text', text: 'human goes first' }])
await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked') await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked')
@@ -364,7 +364,7 @@ describe('same-session goal driving', () => {
test.ctx.on('agent/inbox/enqueue', (agent, info) => { test.ctx.on('agent/inbox/enqueue', (agent, info) => {
if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return
inserted = true inserted = true
agent.send([{ type: 'text', text: 'human joined the pending batch' }]) agent.followup([{ type: 'text', text: 'human joined the pending batch' }])
}) })
test.ctx.goals.create(test.agent, { objective: 'yield to nested human input', maxGoalRounds: 1 }) test.ctx.goals.create(test.agent, { objective: 'yield to nested human input', maxGoalRounds: 1 })
@@ -479,16 +479,16 @@ describe('same-session goal driving', () => {
expect(injectedTurn).toBeGreaterThan(goalTurn) expect(injectedTurn).toBeGreaterThan(goalTurn)
}) })
it('blocks the goal when a custom agent rejects the otherwise valid send', async () => { it('blocks the goal when a custom agent rejects the otherwise valid follow-up', async () => {
const test = await harness([]) const test = await harness([])
// inject shares send, so reject only the round send (a goal-sourced // Reject only the goal-sourced round follow-up, not the state-change injection
// next-turn item), not the goal state-change injection that precedes it. // that precedes it.
const realSend = test.agent.send.bind(test.agent) const realFollowup = test.agent.followup.bind(test.agent)
vi.spyOn(test.agent, 'send').mockImplementation((content, options) => { vi.spyOn(test.agent, 'followup').mockImplementation((content, options) => {
if (options?.source?.kind === 'goal' && (options.target ?? 'next-turn') === 'next-turn') { if (options?.source?.kind === 'goal') {
throw new Error('queue rejected') throw new Error('queue rejected')
} }
return realSend(content, options) return realFollowup(content, options)
}) })
test.ctx.goals.create(test.agent, { objective: 'handle queue failure' }) test.ctx.goals.create(test.agent, { objective: 'handle queue failure' })
@@ -502,15 +502,15 @@ describe('same-session goal driving', () => {
expect(test.adapter.requests).toHaveLength(0) expect(test.adapter.requests).toHaveLength(0)
}) })
it('preserves a custom agent side effect when send disarms before throwing', async () => { it('preserves a custom agent side effect when followup disarms before throwing', async () => {
const test = await harness([]) const test = await harness([])
const realSend = test.agent.send.bind(test.agent) const realFollowup = test.agent.followup.bind(test.agent)
vi.spyOn(test.agent, 'send').mockImplementation((content, options) => { vi.spyOn(test.agent, 'followup').mockImplementation((content, options) => {
if (options?.source?.kind === 'goal' && (options.target ?? 'next-turn') === 'next-turn') { if (options?.source?.kind === 'goal') {
test.ctx.goals.disarm(test.agent) test.ctx.goals.disarm(test.agent)
throw new Error('queue rejected after disarm') throw new Error('queue rejected after disarm')
} }
return realSend(content, options) return realFollowup(content, options)
}) })
test.ctx.goals.create(test.agent, { objective: 'preserve the newer activation state' }) test.ctx.goals.create(test.agent, { objective: 'preserve the newer activation state' })
@@ -621,7 +621,7 @@ describe('same-session goal driving', () => {
it('does not invent goal state when ordinary queued work is cancelled', async () => { it('does not invent goal state when ordinary queued work is cancelled', async () => {
const test = await harness([]) const test = await harness([])
test.agent.send([{ type: 'text', text: 'cancel ordinary work' }]) test.agent.followup([{ type: 'text', text: 'cancel ordinary work' }])
test.agent.cancel({ kind: 'user' }) test.agent.cancel({ kind: 'user' })
await test.agent.whenIdle() await test.agent.whenIdle()
@@ -631,7 +631,7 @@ describe('same-session goal driving', () => {
it('disarms without durably pausing when cancellation belongs to unrelated human work', async () => { it('disarms without durably pausing when cancellation belongs to unrelated human work', async () => {
const test = await harness(['hang']) const test = await harness(['hang'])
test.agent.send([{ type: 'text', text: 'inspect something first' }]) test.agent.followup([{ type: 'text', text: 'inspect something first' }])
await waitForRequests(test.adapter, 1) await waitForRequests(test.adapter, 1)
const created = test.ctx.goals.create(test.agent, { objective: 'continue after inspection' }) const created = test.ctx.goals.create(test.agent, { objective: 'continue after inspection' })

View File

@@ -18,7 +18,7 @@ An autonomous goal round that successfully reports `complete` or `blocked` contr
Execution requires the exact live `exec.agent`, its inherited `AgentRegistry` initiator, running status, and an open turn. Create, edit, pause, and resume additionally require an accepted `{ kind: 'user' }` message or steering event in a runtime-root agent's current turn. Durable fork lineage does not demote a resumed root; live subagent ownership does. Execution requires the exact live `exec.agent`, its inherited `AgentRegistry` initiator, running status, and an open turn. Create, edit, pause, and resume additionally require an accepted `{ kind: 'user' }` message or steering event in a runtime-root agent's current turn. Durable fork lineage does not demote a resumed root; live subagent ownership does.
`{ kind: 'user' }` is a host attestation. `Agent.send()` and `steer()` assign it when their caller omits a source, so plugins, schedulers, and other non-human producers must pass their own source rather than inheriting human authority. `{ kind: 'user' }` is a host attestation. `Agent.followup()` and `steer()` assign it when their caller omits a source, so plugins, schedulers, and other non-human producers must pass their own source rather than inheriting human authority.
Complete and blocked also accept the exact current goal round: a goal-sourced `user/message` whose id, revision, and round equal the folded current goal. A goal-round blocked call is mechanically rejected until `blockedAfterConsecutiveRounds`; the model judges whether the same condition actually persisted and must describe it in `blocked_reason`. Direct human authority may stop a goal immediately. Complete and blocked also accept the exact current goal round: a goal-sourced `user/message` whose id, revision, and round equal the folded current goal. A goal-round blocked call is mechanically rejected until `blockedAfterConsecutiveRounds`; the model judges whether the same condition actually persisted and must describe it in `blocked_reason`. Direct human authority may stop a goal immediately.

View File

@@ -64,7 +64,7 @@ export function goalToolExecution(ctx: Context, exec: ToolRunContext): GoalToolE
/** /**
* Whether host-attested human input appears in the current root-agent turn. * Whether host-attested human input appears in the current root-agent turn.
* An omitted `Agent.send()` / `steer()` source resolves to `user`, so non-human * An omitted `Agent.followup()` / `steer()` source resolves to `user`, so non-human
* producers must supply their own source rather than inheriting this authority. * producers must supply their own source rather than inheriting this authority.
*/ */
function hasDirectHumanInput(ctx: Context, execution: GoalToolExecution): boolean { function hasDirectHumanInput(ctx: Context, execution: GoalToolExecution): boolean {

View File

@@ -56,7 +56,7 @@ describe('threshold escalation', () => {
]) ])
ctx.llm.registerAdapter(['mock'], adapter) ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
const found = reminders(agent) const found = reminders(agent)
@@ -77,7 +77,7 @@ describe('threshold escalation', () => {
]) ])
ctx.llm.registerAdapter(['mock'], adapter) ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
const found = reminders(agent) const found = reminders(agent)
@@ -99,7 +99,7 @@ describe('chain semantics', () => {
]) ])
ctx.llm.registerAdapter(['mock'], adapter) ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
const found = reminders(agent) const found = reminders(agent)
@@ -123,7 +123,7 @@ describe('chain semantics', () => {
]) ])
ctx.llm.registerAdapter(['mock'], adapter) ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(reminders(agent)).toHaveLength(1) expect(reminders(agent)).toHaveLength(1)
@@ -141,7 +141,7 @@ describe('chain semantics', () => {
]) ])
ctx.llm.registerAdapter(['mock'], adapter) ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
const found = reminders(agent) const found = reminders(agent)
@@ -162,7 +162,7 @@ describe('chain semantics', () => {
]) ])
ctx.llm.registerAdapter(['mock'], adapter) ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
const found = reminders(agent) const found = reminders(agent)
@@ -178,7 +178,7 @@ describe('chain semantics', () => {
]) ])
ctx.llm.registerAdapter(['mock'], adapter) ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(reminders(agent)).toHaveLength(1) // probe was NOT excluded expect(reminders(agent)).toHaveLength(1) // probe was NOT excluded
@@ -194,7 +194,7 @@ describe('chain semantics', () => {
]) ])
ctx.llm.registerAdapter(['mock'], adapter) ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(reminders(agent)).toHaveLength(1) // all three canonicalize identically expect(reminders(agent)).toHaveLength(1) // all three canonicalize identically
@@ -215,8 +215,8 @@ describe('chain semantics', () => {
])) ]))
const agentA = ctx.agentLoop.create(SessionId('a'), { provider: 'mock-a', model: 'model-a' }) const agentA = ctx.agentLoop.create(SessionId('a'), { provider: 'mock-a', model: 'model-a' })
const agentB = ctx.agentLoop.create(SessionId('b'), { provider: 'mock-b', model: 'model-b' }) const agentB = ctx.agentLoop.create(SessionId('b'), { provider: 'mock-b', model: 'model-b' })
agentA.send([{ type: 'text', text: 'go' }]) agentA.followup([{ type: 'text', text: 'go' }])
agentB.send([{ type: 'text', text: 'go' }]) agentB.followup([{ type: 'text', text: 'go' }])
await Promise.all([waitForIdle(ctx, agentA), waitForIdle(ctx, agentB)]) await Promise.all([waitForIdle(ctx, agentA), waitForIdle(ctx, agentB)])
expect(reminders(agentA)).toHaveLength(0) // 2 repeats < 3, despite B's 3 in the same registry expect(reminders(agentA)).toHaveLength(0) // 2 repeats < 3, despite B's 3 in the same registry
@@ -234,9 +234,9 @@ describe('chain semantics', () => {
]) ])
ctx.llm.registerAdapter(['mock'], adapter) ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
agent.send([{ type: 'text', text: 'again' }]) agent.followup([{ type: 'text', text: 'again' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(reminders(agent)).toHaveLength(0) expect(reminders(agent)).toHaveLength(0)
@@ -256,13 +256,13 @@ describe('chain semantics', () => {
const fiber = await ctx.plugin(Object.assign((inner: Context) => { const fiber = await ctx.plugin(Object.assign((inner: Context) => {
first = inner.agentLoop.create(SessionId('reused'), { provider: 'mock', model: 'mock' }) first = inner.agentLoop.create(SessionId('reused'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] })) }, { inject: ['agentLoop'] }))
first.send([{ type: 'text', text: 'go' }]) first.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, first) await waitForIdle(ctx, first)
await fiber.dispose() await fiber.dispose()
await first.whenIdle() await first.whenIdle()
const second = ctx.agentLoop.create(SessionId('reused'), { provider: 'mock', model: 'mock' }) const second = ctx.agentLoop.create(SessionId('reused'), { provider: 'mock', model: 'mock' })
second.send([{ type: 'text', text: 'go' }]) second.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, second) await waitForIdle(ctx, second)
expect(reminders(second)).toHaveLength(0) expect(reminders(second)).toHaveLength(0)
@@ -278,7 +278,7 @@ describe('chain semantics', () => {
]) ])
ctx.llm.registerAdapter(['mock'], adapter) ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(reminders(agent)).toHaveLength(1) expect(reminders(agent)).toHaveLength(1)
@@ -294,7 +294,7 @@ describe('chain semantics', () => {
textResponse('done'), textResponse('done'),
])) ]))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(reminders(agent)).toHaveLength(0) expect(reminders(agent)).toHaveLength(0)
@@ -316,7 +316,7 @@ describe('fold onto the downstream decision', () => {
]) ])
ctx.llm.registerAdapter(['mock'], adapter) ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
const found = reminders(agent) const found = reminders(agent)
@@ -347,7 +347,7 @@ describe('fold onto the downstream decision', () => {
]) ])
ctx.llm.registerAdapter(['mock'], adapter) ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
const found = reminders(agent) const found = reminders(agent)

View File

@@ -97,7 +97,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(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'do something' }]) agent.followup([{ type: 'text', text: 'do something' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
// The prompt was blocked: model never called, turn ended rejected. // The prompt was blocked: model never called, turn ended rejected.
@@ -120,7 +120,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(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
// The injected context reached the model and is recorded with the plugin source. // The injected context reached the model and is recorded with the plugin source.
@@ -145,7 +145,7 @@ describe('hooks-claude bridge — PreToolUse', () => {
let ran = false let ran = false
ctx.tools.register(defineContentToolFixture({ name: 'danger', description: 'd', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'danger', description: 'd', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'use danger' }]) agent.followup([{ type: 'text', text: 'use danger' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(ran).toBe(false) expect(ran).toBe(false)
@@ -168,7 +168,7 @@ describe('hooks-claude bridge — PreToolUse', () => {
let ran = false let ran = false
ctx.tools.register(defineContentToolFixture({ name: 'safe', description: 's', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ran ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'safe', description: 's', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ran ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'use safe' }]) agent.followup([{ type: 'text', text: 'use safe' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(ran).toBe(true) expect(ran).toBe(true)
@@ -190,7 +190,7 @@ describe('hooks-claude bridge — PostToolUse', () => {
const ctx = await harness(dir, adapter) const ctx = await harness(dir, adapter)
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'raw output' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'raw output' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ 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')
@@ -211,7 +211,7 @@ describe('hooks-claude bridge — PostToolUse', () => {
const ctx = await harness(dir, adapter) const ctx = await harness(dir, adapter)
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
const log = events(agent) const log = events(agent)
@@ -235,7 +235,7 @@ describe('hooks-claude bridge — PostToolUse', () => {
let ran = false let ran = false
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
// `ask` degrades to deny today (FIXME permissions): the tool does not run and the result is isError. // `ask` degrades to deny today (FIXME permissions): the tool does not run and the result is isError.
@@ -264,7 +264,7 @@ describe('hooks-claude bridge — SessionStart', () => {
// fixed sleep that flakes under load. // fixed sleep that flakes under load.
await waitFor(() => events(agent).some(e => e.type === 'user/message' await waitFor(() => events(agent).some(e => e.type === 'user/message'
&& e.data.content.some(b => b.type === 'text' && b.text.includes('project uses tabs')))) && e.data.content.some(b => b.type === 'text' && b.text.includes('project uses tabs'))))
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('project uses tabs') expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('project uses tabs')
@@ -357,7 +357,7 @@ describe('hooks-claude bridge — load resilience', () => {
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(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ 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.
expect(adapter.requests).toHaveLength(1) expect(adapter.requests).toHaveLength(1)
@@ -379,7 +379,7 @@ describe('hooks-claude bridge — load resilience', () => {
await fiber.dispose() await fiber.dispose()
ctx.llm.registerAdapter(['mock'], adapter) ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ 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
expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) // no hook ran expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) // no hook ran

View File

@@ -74,7 +74,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} }) const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} })
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
return { return {
payload: JSON.parse(readFileSync(cap, 'utf8')) as { transcript_path: string }, payload: JSON.parse(readFileSync(cap, 'utf8')) as { transcript_path: string },
@@ -104,7 +104,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
ctx.logger.warn = warn as never ctx.logger.warn = warn as never
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ 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
}) })
@@ -120,7 +120,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
let sawArgs: unknown let sawArgs: unknown
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: { command: { type: 'string' } }, async execute(args) { sawArgs = args; return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: { command: { type: 'string' } }, async execute(args) { sawArgs = args; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ 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.
expect((sawArgs as { command?: string }).command).toBe('original') expect((sawArgs as { command?: string }).command).toBe('original')
@@ -136,7 +136,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
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(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
// The prompt proceeded unchanged; no injected context. // The prompt proceeded unchanged; no injected context.
expect(adapter.requests).toHaveLength(1) expect(adapter.requests).toHaveLength(1)
@@ -166,7 +166,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const ctx = await harness(path, adapter) const ctx = await harness(path, adapter)
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ 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')
expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true) expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true)
@@ -191,7 +191,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 }) const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 })
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ 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')
expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…') expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…')
@@ -207,7 +207,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
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(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(2) expect(adapter.requests).toHaveLength(2)
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('continue please') expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('continue please')
@@ -223,7 +223,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
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(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ 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.
expect(adapter.requests).toHaveLength(2) expect(adapter.requests).toHaveLength(2)
@@ -271,7 +271,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const ctx = await harness(path, adapter) const ctx = await harness(path, adapter)
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'x' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'x' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ 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')
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true) expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true)
@@ -285,7 +285,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const ctx = await harness(path, adapter) const ctx = await harness(path, adapter)
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ 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')
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PostToolUse hook'))).toBe(true) expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PostToolUse hook'))).toBe(true)
@@ -314,7 +314,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
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(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ 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')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'rejected' && turnEnd.data.reason.reason).toContain('blocked by UserPromptSubmit hook') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'rejected' && turnEnd.data.reason.reason).toContain('blocked by UserPromptSubmit hook')
@@ -329,7 +329,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
let ran = false let ran = false
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ 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.
expect(ran).toBe(false) expect(ran).toBe(false)
@@ -344,7 +344,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const ctx = await harness(path, adapter) const ctx = await harness(path, adapter)
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ 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')
expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0) expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0)
@@ -369,7 +369,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
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(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(existsSync(marker)).toBe(true) expect(existsSync(marker)).toBe(true)
}) })
@@ -384,7 +384,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
let ran = false let ran = false
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(ran).toBe(true) expect(ran).toBe(true)
const res = events(agent).find(e => e.type === 'hook/result') const res = events(agent).find(e => e.type === 'hook/result')
@@ -399,7 +399,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const ctx = await harness(path, adapter) const ctx = await harness(path, adapter)
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ 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')
expect(result?.type === 'tool/result' && result.data.isError).toBe(true) expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
@@ -418,7 +418,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
let ran = false let ran = false
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ 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')
expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded
@@ -435,7 +435,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const ctx = await harness(path, adapter) const ctx = await harness(path, adapter)
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ 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')
expect(result?.type === 'tool/result' && result.data.isError).toBe(true) expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
@@ -455,7 +455,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
let ran = false let ran = false
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ 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
}) })
@@ -473,7 +473,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
// 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({ sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { provider: 'mock', model: 'mock' } }) const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { provider: 'mock', model: 'mock' } })
handle.agent.send([{ type: 'text', text: 'go' }]) handle.agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, handle.agent) await waitForIdle(ctx, handle.agent)
expect(events(handle.agent).some(e => e.type === 'user/message' expect(events(handle.agent).some(e => e.type === 'user/message'
&& e.data.content.some(b => b.type === 'text' && b.text.includes(`dir=${workspace}`)))).toBe(true) && e.data.content.some(b => b.type === 'text' && b.text.includes(`dir=${workspace}`)))).toBe(true)
@@ -491,7 +491,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
// A later listener that blocks every prompt (registered AFTER the bridge). // A later listener that blocks every prompt (registered AFTER the bridge).
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(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
// the downstream block won: the model was never called, no user/message was // the downstream block won: the model was never called, no user/message was
// recorded, and the (sole, fully-blocked) prompt closed the turn `rejected` // recorded, and the (sole, fully-blocked) prompt closed the turn `rejected`
@@ -518,7 +518,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
}], }],
})) }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ 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)
expect(req).toContain('from-bridge') expect(req).toContain('from-bridge')
@@ -545,7 +545,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: [{ type: 'text' as const, text: 'rewritten-result' }] })) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: [{ type: 'text' as const, text: 'rewritten-result' }] }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ 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')
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)
@@ -567,7 +567,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
}], }],
})) }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
@@ -589,7 +589,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ 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(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ 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')
expect(result?.type === 'tool/result' && result.data.isError).toBe(true) expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
@@ -613,7 +613,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
bash.run = (() => Promise.reject(new Error('executor down'))) bash.run = (() => Promise.reject(new Error('executor down')))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ 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')
expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false) expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false)
@@ -636,7 +636,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
await waitFor(() => threw) await waitFor(() => threw)
expect(threw).toBe(true) expect(threw).toBe(true)
agent.inject = original agent.inject = original
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1) // loop survived the thrown inject expect(adapter.requests).toHaveLength(1) // loop survived the thrown inject
}) })
@@ -663,7 +663,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const { SessionId } = await import('@deepseek-ai/dsh-session') const { SessionId } = await import('@deepseek-ai/dsh-session')
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } }) const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } })
handle.agent.send([{ type: 'text', text: 'go' }]) handle.agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, handle.agent) await waitForIdle(ctx, handle.agent)
expect(existsSync(marker)).toBe(true) // the marker landed in the SESSION dir expect(existsSync(marker)).toBe(true) // the marker landed in the SESSION dir
@@ -713,7 +713,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
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(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage')) expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage'))
// Not surfaced: the systemMessage text never reaches the model request. // Not surfaced: the systemMessage text never reaches the model request.
@@ -732,7 +732,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const ctx = await harness(path, adapter) const ctx = await harness(path, adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// Send immediately — do NOT wait for the session-start inject. // Send immediately — do NOT wait for the session-start inject.
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1) // the turn ran regardless of hook timing expect(adapter.requests).toHaveLength(1) // the turn ran regardless of hook timing
}) })

View File

@@ -77,7 +77,7 @@ describe('hooks-codex bridge', () => {
let ran = false let ran = false
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'no' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'no' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'run ls' }]) agent.followup([{ type: 'text', text: 'run ls' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(ran).toBe(false) expect(ran).toBe(false)
@@ -98,7 +98,7 @@ describe('hooks-codex bridge', () => {
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(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(2) expect(adapter.requests).toHaveLength(2)
@@ -115,7 +115,7 @@ describe('hooks-codex bridge', () => {
const adapter = new MockAdapter([textResponse('must not run')]) const adapter = new MockAdapter([textResponse('must not run')])
const ctx = await harness(dir, adapter) const ctx = await harness(dir, adapter)
const agent = ctx.agentLoop.create(SessionId('cancel-prompt-hook'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('cancel-prompt-hook'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'cancel the hook' }]) agent.followup([{ type: 'text', text: 'cancel the hook' }])
await waitFor(() => existsSync(marker)) await waitFor(() => existsSync(marker))
const pid = Number(readFileSync(pidFile, 'utf8').trim()) const pid = Number(readFileSync(pidFile, 'utf8').trim())
@@ -139,7 +139,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(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1) expect(adapter.requests).toHaveLength(1)
}) })
@@ -149,7 +149,7 @@ describe('hooks-codex bridge', () => {
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(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1) expect(adapter.requests).toHaveLength(1)
}) })
@@ -169,7 +169,7 @@ describe('hooks-codex bridge', () => {
await fiber.dispose() await fiber.dispose()
ctx.llm.registerAdapter(['mock'], adapter) ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ 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
expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) // no hook ran expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) // no hook ran

View File

@@ -65,7 +65,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} }) const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} })
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
return { return {
payload: JSON.parse(readFileSync(cap, 'utf8')) as { transcript_path: string | null }, payload: JSON.parse(readFileSync(cap, 'utf8')) as { transcript_path: string | null },
@@ -84,7 +84,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
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(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) agent.followup([{ 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')
expect(te?.type === 'turn/end' && te.data.reason.kind).toBe('rejected') expect(te?.type === 'turn/end' && te.data.reason.kind).toBe('rejected')
@@ -96,7 +96,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
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(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) agent.followup([{ 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')
}) })
@@ -109,7 +109,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
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(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) agent.followup([{ 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)
const te = events(agent).findLast(e => e.type === 'turn/end') const te = events(agent).findLast(e => e.type === 'turn/end')
@@ -130,7 +130,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
}], }],
})) }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) agent.followup([{ 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')
expect(req).toContain('from-downstream') expect(req).toContain('from-downstream')
@@ -152,7 +152,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: [{ type: 'text' as const, text: 'rewritten-result' }] })) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: [{ type: 'text' as const, text: 'rewritten-result' }] }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) agent.followup([{ 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)
expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
@@ -172,7 +172,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
}], }],
})) }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([ expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([
@@ -189,7 +189,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ 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(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) agent.followup([{ 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)
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true) expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true)
@@ -204,7 +204,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
await waitFor(() => events(agent).some(e => e.type === 'user/message' await waitFor(() => events(agent).some(e => e.type === 'user/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.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('start-ctx') expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('start-ctx')
}) })
@@ -215,7 +215,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = await harness(join(d, 'hooks.json'), adapter) const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) agent.followup([{ 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)
expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PostToolUse hook'))).toBe(true) expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PostToolUse hook'))).toBe(true)
@@ -228,7 +228,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = await harness(join(d, 'hooks.json'), adapter) const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true) expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true)
}) })
}) })
@@ -242,7 +242,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
let ran = false let ran = false
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) agent.followup([{ 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 ''
}) })
@@ -253,7 +253,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = await harness(join(d, 'hooks.json'), adapter) const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) agent.followup([{ 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)
expect(res?.type === 'hook/result' && 'stderrSummary' in res.data).toBe(false) expect(res?.type === 'hook/result' && 'stderrSummary' in res.data).toBe(false)
@@ -266,7 +266,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = await harness(join(d, 'hooks.json'), adapter) const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) agent.followup([{ 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)
expect(res?.type === 'hook/result' && res.data.stderrSummary?.length).toBe(501) // default 500-char cap + ellipsis expect(res?.type === 'hook/result' && res.data.stderrSummary?.length).toBe(501) // default 500-char cap + ellipsis
@@ -289,7 +289,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
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(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) agent.followup([{ 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) + '…')
}) })
@@ -312,7 +312,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
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(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) agent.followup([{ 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'))
}) })
@@ -325,7 +325,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
let ran = false let ran = false
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(ran).toBe(true) expect(ran).toBe(true)
}) })
@@ -340,7 +340,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = await harness(join(d, 'hooks.json'), adapter) const ctx = await harness(join(d, 'hooks.json'), adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
await waitFor(() => existsSync(marker)) // the clean no-output hook has finished await waitFor(() => existsSync(marker)) // the clean no-output hook has finished
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(false) expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(false)
}) })
@@ -366,7 +366,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
let ran = false let ran = false
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(ran).toBe(true) expect(ran).toBe(true)
}) })
@@ -379,7 +379,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
let ran = false let ran = false
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) agent.followup([{ 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)
}) })
@@ -395,7 +395,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
let ran = false let ran = false
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) agent.followup([{ 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
expect(ran).toBe(true) // NOT honored: the tool still ran (halt is deferred) expect(ran).toBe(true) // NOT honored: the tool still ran (halt is deferred)
@@ -408,7 +408,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = await harness(join(d, 'hooks.json'), adapter) const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) agent.followup([{ 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)
}) })
@@ -420,7 +420,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = await harness(join(d, 'hooks.json'), adapter) const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) agent.followup([{ 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)
expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true) expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true)
@@ -437,7 +437,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = await harness(join(d, 'hooks.json'), adapter) const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'number' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'number' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) agent.followup([{ 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('')
}) })
@@ -473,7 +473,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
ctx.bash.run = (() => Promise.reject(new Error('executor down'))) ctx.bash.run = (() => Promise.reject(new Error('executor down')))
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) agent.followup([{ 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)
}) })
@@ -489,7 +489,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
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(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) agent.followup([{ 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')
}) })
@@ -502,7 +502,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
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(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) agent.followup([{ 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')
}) })
@@ -530,7 +530,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
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(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) agent.followup([{ 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')
}) })
@@ -543,7 +543,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
await waitFor(() => events(agent).some(e => e.type === 'user/message' await waitFor(() => events(agent).some(e => e.type === 'user/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.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble') expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble')
}) })
@@ -555,7 +555,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
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(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) agent.followup([{ 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')
}) })
@@ -570,7 +570,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = await harness(join(d, 'hooks.json'), adapter) const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineContentToolFixture({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) agent.followup([{ 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')
expect(payload.tool_input.command).toBe('ls') expect(payload.tool_input.command).toBe('ls')
@@ -586,7 +586,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
let ran = false let ran = false
ctx.tools.register(defineContentToolFixture({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) agent.followup([{ 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)
}) })
@@ -598,7 +598,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
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(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) agent.followup([{ 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')
}) })
@@ -621,7 +621,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.tools.register(defineContentToolFixture({ 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({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } }) const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } })
handle.agent.send([{ type: 'text', text: 'go' }]) handle.agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, handle.agent) await waitForIdle(ctx, handle.agent)
expect(existsSync(marker)).toBe(true) expect(existsSync(marker)).toBe(true)
expect(readFileSync(marker, 'utf8').trim().endsWith(sessionDir.split('/').pop()!)).toBe(true) expect(readFileSync(marker, 'utf8').trim().endsWith(sessionDir.split('/').pop()!)).toBe(true)

View File

@@ -372,7 +372,7 @@ describe('sessions.prompt / cancel', () => {
const { api, ctx } = running const { api, ctx } = running
const { sessionId } = expectOk(await api.sessions.create(request({}))) const { sessionId } = expectOk(await api.sessions.create(request({})))
const agent = ctx.agents.get(sessionId) as Agent const agent = ctx.agents.get(sessionId) as Agent
agent.send([{ type: 'text', text: 'run forever' }]) agent.followup([{ type: 'text', text: 'run forever' }])
expectOk(await api.sessions.cancel(request({ sessionId }))) expectOk(await api.sessions.cancel(request({ sessionId })))
const missing = await api.sessions.cancel(request({ sessionId: 'session-none' as SessionId })) const missing = await api.sessions.cancel(request({ sessionId: 'session-none' as SessionId }))
@@ -391,7 +391,7 @@ describe('sessions.history', () => {
const { sessionId } = expectOk(await first.api.sessions.create(request({}))) const { sessionId } = expectOk(await first.api.sessions.create(request({})))
const agent = first.ctx.agents.get(sessionId) as Agent const agent = first.ctx.agents.get(sessionId) as Agent
const idle = waitForIdle(first.ctx, agent) const idle = waitForIdle(first.ctx, agent)
agent.send([{ type: 'text', text: 'save me' }]) agent.followup([{ type: 'text', text: 'save me' }])
await idle await idle
const titleEvent = await appendTitle(first.ctx, agent, 'Persisted title') const titleEvent = await appendTitle(first.ctx, agent, 'Persisted title')
await first.dispose() await first.dispose()
@@ -440,7 +440,7 @@ describe('sessions.history', () => {
const agent = ctx.agents.get(sessionId) as Agent const agent = ctx.agents.get(sessionId) as Agent
for (const text of ['q1', 'q2', 'q3']) { for (const text of ['q1', 'q2', 'q3']) {
const idle = waitForIdle(ctx, agent) const idle = waitForIdle(ctx, agent)
agent.send([{ type: 'text', text }]) agent.followup([{ type: 'text', text }])
await idle await idle
} }
@@ -511,7 +511,7 @@ describe('events streams', () => {
const agent = ctx.agents.get(sessionId) as Agent const agent = ctx.agents.get(sessionId) as Agent
const idle = waitForIdle(ctx, agent) const idle = waitForIdle(ctx, agent)
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await idle await idle
const live = await stream.next() const live = await stream.next()
expect((live.value as RpcRequest<MuxFrame>).payload.type).toBe('session/event') expect((live.value as RpcRequest<MuxFrame>).payload.type).toBe('session/event')
@@ -574,7 +574,7 @@ describe('events streams', () => {
const agent = ctx.agents.get(sessionId) as Agent const agent = ctx.agents.get(sessionId) as Agent
const idle = waitForIdle(ctx, agent) const idle = waitForIdle(ctx, agent)
agent.send([{ type: 'text', text: 'run' }]) agent.followup([{ type: 'text', text: 'run' }])
await idle await idle
const runningFrame = await stream.next() const runningFrame = await stream.next()
expect((runningFrame.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/session-status', running: true }) expect((runningFrame.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/session-status', running: true })

View File

@@ -114,7 +114,7 @@ describe('real Loader composition', () => {
loaded.llm.registerAdapter(['mock'], adapter) loaded.llm.registerAdapter(['mock'], adapter)
const agent = loaded.agentLoop.create(SessionId('loader-retry'), { provider: 'mock', model: 'mock' }) const agent = loaded.agentLoop.create(SessionId('loader-retry'), { provider: 'mock', model: 'mock' })
const idle = waitForIdle(loaded, agent) const idle = waitForIdle(loaded, agent)
agent.send([{ type: 'text', text: 'recover' }]) agent.followup([{ type: 'text', text: 'recover' }])
await idle await idle
expect(adapter.requests).toBe(2) expect(adapter.requests).toBe(2)

View File

@@ -129,7 +129,7 @@ describe('bounded transient retry policy', () => {
}) })
}) })
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
const event = await scheduled const event = await scheduled
expect(event.data).toEqual({ expect(event.data).toEqual({
@@ -178,7 +178,7 @@ describe('bounded transient retry policy', () => {
const agent = context.agentLoop.create(SessionId('retry-partial'), { provider: 'mock', model: 'mock' }) const agent = context.agentLoop.create(SessionId('retry-partial'), { provider: 'mock', model: 'mock' })
const scheduled = waitForRetry(context, agent, 1) const scheduled = waitForRetry(context, agent, 1)
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await scheduled await scheduled
const idle = waitForIdle(context, agent) const idle = waitForIdle(context, agent)
await vi.advanceTimersByTimeAsync(500) await vi.advanceTimersByTimeAsync(500)
@@ -213,7 +213,7 @@ describe('bounded transient retry policy', () => {
const agent = context.agentLoop.create(SessionId('retry-exhausted'), { provider: 'mock', model: 'mock' }) const agent = context.agentLoop.create(SessionId('retry-exhausted'), { provider: 'mock', model: 'mock' })
const first = waitForRetry(context, agent, 1) const first = waitForRetry(context, agent, 1)
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
expect((await first).data.delayMs).toBe(450) expect((await first).data.delayMs).toBe(450)
const second = waitForRetry(context, agent, 2) const second = waitForRetry(context, agent, 2)
@@ -246,7 +246,7 @@ describe('bounded transient retry policy', () => {
const agent = context.agentLoop.create(SessionId('retry-zero-delay'), { provider: 'mock', model: 'mock' }) const agent = context.agentLoop.create(SessionId('retry-zero-delay'), { provider: 'mock', model: 'mock' })
const scheduled = waitForRetry(context, agent, 1) const scheduled = waitForRetry(context, agent, 1)
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
expect((await scheduled).data.delayMs).toBe(0) expect((await scheduled).data.delayMs).toBe(0)
const idle = waitForIdle(context, agent) const idle = waitForIdle(context, agent)
@@ -264,7 +264,7 @@ describe('bounded transient retry policy', () => {
;({ ctx: context } = await harness(accepted, { jitterRatio: 1 })) ;({ ctx: context } = await harness(accepted, { jitterRatio: 1 }))
const acceptedAgent = context.agentLoop.create(SessionId('retry-after-accepted'), { provider: 'mock', model: 'mock' }) const acceptedAgent = context.agentLoop.create(SessionId('retry-after-accepted'), { provider: 'mock', model: 'mock' })
const scheduled = waitForRetry(context, acceptedAgent, 1) const scheduled = waitForRetry(context, acceptedAgent, 1)
acceptedAgent.send([{ type: 'text', text: 'go' }]) acceptedAgent.followup([{ type: 'text', text: 'go' }])
expect((await scheduled).data.delayMs).toBe(2_000) expect((await scheduled).data.delayMs).toBe(2_000)
const acceptedIdle = waitForIdle(context, acceptedAgent) const acceptedIdle = waitForIdle(context, acceptedAgent)
await vi.advanceTimersByTimeAsync(2_000) await vi.advanceTimersByTimeAsync(2_000)
@@ -278,7 +278,7 @@ describe('bounded transient retry policy', () => {
;({ ctx: context } = await harness(rejected)) ;({ ctx: context } = await harness(rejected))
const rejectedAgent = context.agentLoop.create(SessionId('retry-after-rejected'), { provider: 'mock', model: 'mock' }) const rejectedAgent = context.agentLoop.create(SessionId('retry-after-rejected'), { provider: 'mock', model: 'mock' })
const rejectedIdle = waitForIdle(context, rejectedAgent) const rejectedIdle = waitForIdle(context, rejectedAgent)
rejectedAgent.send([{ type: 'text', text: 'go' }]) rejectedAgent.followup([{ type: 'text', text: 'go' }])
await rejectedIdle await rejectedIdle
expect(rejected.requests).toHaveLength(1) expect(rejected.requests).toHaveLength(1)
expect(rejectedAgent.session.events.some(event => event.type === 'llm/retry')).toBe(false) expect(rejectedAgent.session.events.some(event => event.type === 'llm/retry')).toBe(false)
@@ -290,7 +290,7 @@ describe('bounded transient retry policy', () => {
;({ ctx: context } = await harness(adapter)) ;({ ctx: context } = await harness(adapter))
const agent = context.agentLoop.create(SessionId('retry-auth'), { provider: 'mock', model: 'mock' }) const agent = context.agentLoop.create(SessionId('retry-auth'), { provider: 'mock', model: 'mock' })
const idle = waitForIdle(context, agent) const idle = waitForIdle(context, agent)
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await idle await idle
expect(adapter.requests).toHaveLength(1) expect(adapter.requests).toHaveLength(1)
expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false)
@@ -307,7 +307,7 @@ describe('bounded transient retry policy', () => {
context = mounted.ctx context = mounted.ctx
const agent = context.agentLoop.create(SessionId('retry-hmr'), { provider: 'mock', model: 'mock' }) const agent = context.agentLoop.create(SessionId('retry-hmr'), { provider: 'mock', model: 'mock' })
const scheduled = waitForRetry(context, agent, 1) const scheduled = waitForRetry(context, agent, 1)
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await scheduled await scheduled
const idle = waitForIdle(context, agent) const idle = waitForIdle(context, agent)
@@ -335,7 +335,7 @@ describe('bounded transient retry policy', () => {
model: 'mock', model: 'mock',
}) })
const idle = waitForIdle(context, agent) const idle = waitForIdle(context, agent)
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await entered.promise await entered.promise
const disposing = mounted.retryFiber.dispose() const disposing = mounted.retryFiber.dispose()
@@ -376,7 +376,7 @@ describe('bounded transient retry policy', () => {
model: 'mock', model: 'mock',
}) })
const idle = waitForIdle(context, agent) const idle = waitForIdle(context, agent)
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await captured.promise await captured.promise
await mounted.retryFiber.dispose() await mounted.retryFiber.dispose()
@@ -397,7 +397,7 @@ describe('bounded transient retry policy', () => {
;({ ctx: context } = await harness(adapter)) ;({ ctx: context } = await harness(adapter))
const agent = context.agentLoop.create(SessionId('retry-cancel'), { provider: 'mock', model: 'mock' }) const agent = context.agentLoop.create(SessionId('retry-cancel'), { provider: 'mock', model: 'mock' })
const scheduled = waitForRetry(context, agent, 1) const scheduled = waitForRetry(context, agent, 1)
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await scheduled await scheduled
const idle = waitForIdle(context, agent) const idle = waitForIdle(context, agent)
agent.cancel({ kind: 'user' }) agent.cancel({ kind: 'user' })
@@ -426,7 +426,7 @@ describe('bounded transient retry policy', () => {
const agent = context.agentLoop.create(SessionId('retry-pre-cancel'), { provider: 'mock', model: 'mock' }) const agent = context.agentLoop.create(SessionId('retry-pre-cancel'), { provider: 'mock', model: 'mock' })
const idle = waitForIdle(context, agent) const idle = waitForIdle(context, agent)
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await idle await idle
expect(adapter.requests).toHaveLength(1) expect(adapter.requests).toHaveLength(1)
@@ -450,7 +450,7 @@ describe('bounded transient retry policy', () => {
}) })
const idle = waitForIdle(context, agent) const idle = waitForIdle(context, agent)
agent.send([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await idle await idle
expect(adapter.requests).toHaveLength(1) expect(adapter.requests).toHaveLength(1)

View File

@@ -75,7 +75,7 @@ describe('plan mode through the agent loop', () => {
// the first prompt-submit, BEFORE the first assembly. // the first prompt-submit, BEFORE the first assembly.
ctx.planMode.set(agent, true) ctx.planMode.set(agent, true)
agent.send([{ type: 'text', text: 'explore the repo' }]) agent.followup([{ type: 'text', text: 'explore the repo' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
const log = agent.session.events const log = agent.session.events
@@ -103,14 +103,14 @@ describe('plan mode through the agent loop', () => {
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-plan-flip'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('it-plan-flip'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'hello' }]) agent.followup([{ type: 'text', text: 'hello' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(foldPlanMode(agent.session.events)).toBe(false) expect(foldPlanMode(agent.session.events)).toBe(false)
const first = findEvent(agent.session.events, 'request/header') const first = findEvent(agent.session.events, 'request/header')
expect(first.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'write']) expect(first.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'write'])
ctx.planMode.set(agent, true) ctx.planMode.set(agent, true)
agent.send([{ type: 'text', text: 'now plan' }]) agent.followup([{ type: 'text', text: 'now plan' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
const log = agent.session.events const log = agent.session.events
@@ -146,7 +146,7 @@ describe('plan mode through the agent loop', () => {
}) })
const idle = waitForIdle(ctx, agent) const idle = waitForIdle(ctx, agent)
agent.send([{ type: 'text', text: 'plan after the transient failure' }]) agent.followup([{ type: 'text', text: 'plan after the transient failure' }])
await recoveryEntered.promise await recoveryEntered.promise
ctx.planMode.set(agent, true) ctx.planMode.set(agent, true)
releaseRecovery.resolve(true) releaseRecovery.resolve(true)

View File

@@ -42,7 +42,7 @@ function agent(ctx: Context): Agent {
const id = SessionId('agent') const id = SessionId('agent')
return { return {
id, options: {}, session: new Session(id), status: 'idle', ctx, id, options: {}, session: new Session(id), status: 'idle', ctx,
send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
} }
} }
@@ -244,7 +244,7 @@ describe('pty-local plugin shape', () => {
const ownerFiber = await ctx.plugin(() => {}) const ownerFiber = await ctx.plugin(() => {})
const owner: Agent = { const owner: Agent = {
id: session.id, options: {}, session, status: 'idle', ctx: ownerFiber.ctx, id: session.id, options: {}, session, status: 'idle', ctx: ownerFiber.ctx,
send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
} }
ctx.agents.register(owner) ctx.agents.register(owner)
const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession()) const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession())
@@ -287,7 +287,7 @@ describe('pty-local plugin shape', () => {
const ownerFiber = await ctx.plugin(() => {}) const ownerFiber = await ctx.plugin(() => {})
const owner: Agent = { const owner: Agent = {
id: session.id, options: {}, session, status: 'idle', ctx: ownerFiber.ctx, id: session.id, options: {}, session, status: 'idle', ctx: ownerFiber.ctx,
send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
} }
ctx.agents.register(owner) ctx.agents.register(owner)
const gate = Promise.withResolvers<undefined>() const gate = Promise.withResolvers<undefined>()

View File

@@ -35,7 +35,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
const scope = ctx.plugin(() => {}) const scope = ctx.plugin(() => {})
return { return {
id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx,
send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
} }
} }

View File

@@ -27,10 +27,11 @@ function stubAgent(ctx: Context, rawId: string): Agent {
session: new Session(id), session: new Session(id),
status: 'idle', status: 'idle',
ctx: scopeFiber.ctx, ctx: scopeFiber.ctx,
send: () => AgentMessageId('stub'),
followup: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'),
queue: () => AgentMessageId('stub'),
steer: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'),
inject: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'),
send: () => AgentMessageId('stub'),
cancel() {}, cancel() {},
whenIdle: () => Promise.resolve(), whenIdle: () => Promise.resolve(),
} }

View File

@@ -40,7 +40,7 @@ function agent(ctx: Context): Agent {
const id = SessionId('pty-loader-agent') const id = SessionId('pty-loader-agent')
const value: Agent = { const value: Agent = {
id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx,
send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
} }
ctx.agents.register(value) ctx.agents.register(value)
return value return value

View File

@@ -18,7 +18,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent {
const id = SessionId(rawId) const id = SessionId(rawId)
const agent: Agent = { const agent: Agent = {
id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx,
send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
} }
ctx.agents.register(agent) ctx.agents.register(agent)
return agent return agent

View File

@@ -56,5 +56,5 @@ const handle = await ctx.agents.create({
sessionId: SessionId('semantic-checkpoint-crash'), sessionId: SessionId('semantic-checkpoint-crash'),
agentOptions: { provider: 'crash', model: 'crash' }, agentOptions: { provider: 'crash', model: 'crash' },
}) })
handle.agent.send([{ type: 'text', text: 'exercise the crash boundary' }]) handle.agent.followup([{ type: 'text', text: 'exercise the crash boundary' }])
await waitForCrash() await waitForCrash()

View File

@@ -64,7 +64,7 @@ describe('multi-subagent coexistence (spawn + fork on one context)', () => {
]) ])
// Parent does one real turn first, so the fork has a completed turn to seed. // Parent does one real turn first, so the fork has a completed turn to seed.
parent.send([{ type: 'text', text: 'parent q1' }]) parent.followup([{ type: 'text', text: 'parent q1' }])
await parent.whenIdle() await parent.whenIdle()
const parentPrefixLen = parent.session.events.length const parentPrefixLen = parent.session.events.length
@@ -93,7 +93,7 @@ describe('multi-subagent coexistence (spawn + fork on one context)', () => {
await forkRun.dispose() await forkRun.dispose()
// The parent is unaffected and keeps working after both delegations. // The parent is unaffected and keeps working after both delegations.
parent.send([{ type: 'text', text: 'parent q2' }]) parent.followup([{ type: 'text', text: 'parent q2' }])
await parent.whenIdle() await parent.whenIdle()
const lastParentMessage = parent.session.events.findLast(e => e.type === 'assistant/message') const lastParentMessage = parent.session.events.findLast(e => e.type === 'assistant/message')
expect(lastParentMessage?.type === 'assistant/message' && text(lastParentMessage.data.content)).toBe('parent turn two') expect(lastParentMessage?.type === 'assistant/message' && text(lastParentMessage.data.content)).toBe('parent turn two')

View File

@@ -89,9 +89,9 @@ describe('dsh-subagent-fork', () => {
it('seeds every completed parent turn through the last turn/end', async () => { it('seeds every completed parent turn through the last turn/end', async () => {
const { ctx, parent } = await setup([textResponse('first'), textResponse('second'), textResponse('child')]) const { ctx, parent } = await setup([textResponse('first'), textResponse('second'), textResponse('child')])
parent.send([{ type: 'text', text: 'q1' }]) parent.followup([{ type: 'text', text: 'q1' }])
await parent.whenIdle() await parent.whenIdle()
parent.send([{ type: 'text', text: 'q2' }]) parent.followup([{ type: 'text', text: 'q2' }])
await parent.whenIdle() await parent.whenIdle()
const parentPrefixLen = parent.session.events.length const parentPrefixLen = parent.session.events.length
@@ -108,7 +108,7 @@ describe('dsh-subagent-fork', () => {
// Parent runs one turn, then we fork. The child's seeded log should contain // Parent runs one turn, then we fork. The child's seeded log should contain
// the parent's first turn, and the child should run its own new turn on top. // the parent's first turn, and the child should run its own new turn on top.
const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')]) const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')])
parent.send([{ type: 'text', text: 'parent question' }]) parent.followup([{ type: 'text', text: 'parent question' }])
await parent.whenIdle() await parent.whenIdle()
const parentPrefixLen = parent.session.events.length const parentPrefixLen = parent.session.events.length
@@ -137,10 +137,10 @@ describe('dsh-subagent-fork', () => {
// open (a hanging model call), and fork while it's in flight. The seed must stop after the // open (a hanging model call), and fork while it's in flight. The seed must stop after the
// balanced first turn; including the open turn would fail invariant replay during start. // balanced first turn; including the open turn would fail invariant replay during start.
const { ctx, parent } = await setup([textResponse('done'), 'hang', textResponse('child')]) const { ctx, parent } = await setup([textResponse('done'), 'hang', textResponse('child')])
parent.send([{ type: 'text', text: 'q1' }]) parent.followup([{ type: 'text', text: 'q1' }])
await parent.whenIdle() await parent.whenIdle()
// Start a second turn that hangs (open turn/start + open step, never ends). // Start a second turn that hangs (open turn/start + open step, never ends).
parent.send([{ type: 'text', text: 'q2' }]) parent.followup([{ type: 'text', text: 'q2' }])
await new Promise(r => setTimeout(r, 20)) // let the hanging turn open await new Promise(r => setTimeout(r, 20)) // let the hanging turn open
// Forking now must NOT throw (the open second turn is excluded from the seed). // Forking now must NOT throw (the open second turn is excluded from the seed).
@@ -164,7 +164,7 @@ describe('dsh-subagent-fork', () => {
textResponse('parent turn'), textResponse('parent turn'),
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 9 }), toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 9 }),
]) ])
parent.send([{ type: 'text', text: 'warm up' }]) parent.followup([{ type: 'text', text: 'warm up' }])
await parent.whenIdle() await parent.whenIdle()
const run = await start(ctx, 'fork', { const run = await start(ctx, 'fork', {
prompt: [{ type: 'text', text: 'report structured' }], prompt: [{ type: 'text', text: 'report structured' }],
@@ -183,7 +183,7 @@ describe('dsh-subagent-fork', () => {
// `readResult` must scan only child-owned events after the seed. The child emits no assistant // `readResult` must scan only child-owned events after the seed. The child emits no assistant
// message, so scanning the whole log would incorrectly return the parent's distinctive text. // message, so scanning the whole log would incorrectly return the parent's distinctive text.
const { ctx, parent } = await setup([textResponse('parent stale'), emptyStop]) const { ctx, parent } = await setup([textResponse('parent stale'), emptyStop])
parent.send([{ type: 'text', text: 'parent question' }]) parent.followup([{ type: 'text', text: 'parent question' }])
await parent.whenIdle() await parent.whenIdle()
const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child question' }], parent }) const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child question' }], parent })

View File

@@ -11,7 +11,7 @@ The driver follows this sequence:
1. Validate the parent depth and optional absolute `maxDepth`, then derive child depth as parent depth plus one and persist it in the child session header. 1. Validate the parent depth and optional absolute `maxDepth`, then derive child depth as parent depth plus one and persist it in the child session header.
2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction. 2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction.
3. During that transaction's unpublished setup window, install the requested persona, tool restriction, and structured-output runtime. 3. During that transaction's unpublished setup window, install the requested persona, tool restriction, and structured-output runtime.
4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.send(prompt)` followed by `child.whenIdle()`. 4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`.
5. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned zero-step turns. 5. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned zero-step turns.
The child gets the parent's working-directory/session lineage and inherits the parent model unless `request.agentOptions` overrides it. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset. The child gets the parent's working-directory/session lineage and inherits the parent model unless `request.agentOptions` overrides it. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset.

View File

@@ -141,7 +141,7 @@ export async function startInProcessRun(
const result: Promise<SubagentResult> = (async () => { const result: Promise<SubagentResult> = (async () => {
try { try {
child.send(request.prompt) child.followup(request.prompt)
await child.whenIdle() await child.whenIdle()
return readResult( return readResult(
child, child,

View File

@@ -522,7 +522,7 @@ describe('in-process structured output', () => {
textResponse('parent answer'), textResponse('parent answer'),
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }), toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
]) ])
parent.send([{ type: 'text', text: 'hello' }]) parent.followup([{ type: 'text', text: 'hello' }])
await parent.whenIdle() await parent.whenIdle()
expect(adapter.requests[0]!.system ?? '').not.toContain(STRUCTURED_OUTPUT_INSTRUCTION) expect(adapter.requests[0]!.system ?? '').not.toContain(STRUCTURED_OUTPUT_INSTRUCTION)
const run = await ctx.subagents.start('spawn', structuredRequest(parent)) const run = await ctx.subagents.start('spawn', structuredRequest(parent))
@@ -538,7 +538,7 @@ describe('in-process structured output', () => {
describe('scoped registration (each child owns its capture tool)', () => { describe('scoped registration (each child owns its capture tool)', () => {
it('a plain agent never sees the tool: nothing is registered globally at all', async () => { it('a plain agent never sees the tool: nothing is registered globally at all', async () => {
const { ctx, parent, adapter } = await setup([textResponse('parent answer')]) const { ctx, parent, adapter } = await setup([textResponse('parent answer')])
parent.send([{ type: 'text', text: 'hello' }]) parent.followup([{ type: 'text', text: 'hello' }])
await parent.whenIdle() await parent.whenIdle()
// Scoped registration: the global view has no capture tool, ever. // Scoped registration: the global view has no capture tool, ever.
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined() expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
@@ -552,7 +552,7 @@ describe('in-process structured output', () => {
// Child turn: must see it, with the run's schema. // Child turn: must see it, with the run's schema.
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42 }), toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42 }),
]) ])
parent.send([{ type: 'text', text: 'hello' }]) parent.followup([{ type: 'text', text: 'hello' }])
await parent.whenIdle() await parent.whenIdle()
expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL) expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL)
@@ -630,7 +630,7 @@ describe('in-process structured output', () => {
it('a non-structured agent request keeps tools ABSENT when it had none (no tools: [] materialized)', async () => { it('a non-structured agent request keeps tools ABSENT when it had none (no tools: [] materialized)', async () => {
const { parent, adapter } = await setup([textResponse('plain')]) const { parent, adapter } = await setup([textResponse('plain')])
parent.send([{ type: 'text', text: 'q' }]) parent.followup([{ type: 'text', text: 'q' }])
await parent.whenIdle() await parent.whenIdle()
const request = adapter.requests[0]! const request = adapter.requests[0]!
expect(request.tools).toBeUndefined() expect(request.tools).toBeUndefined()

View File

@@ -87,7 +87,7 @@ describe('startInProcessRun', () => {
it('seeds a forked child but reads only the child-owned output', async () => { it('seeds a forked child but reads only the child-owned output', async () => {
const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')]) const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')])
parent.send([{ type: 'text', text: 'parent question' }]) parent.followup([{ type: 'text', text: 'parent question' }])
await parent.whenIdle() await parent.whenIdle()
const seed = parent.session.events.slice() const seed = parent.session.events.slice()
const run = await startInProcessRun(request(parent), { seed }) const run = await startInProcessRun(request(parent), { seed })

View File

@@ -31,7 +31,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('spawn backend with-key smoke', (
ctx = await spawnHarness(workdir) ctx = await spawnHarness(workdir)
const parent = ctx.agentLoop.create(SessionId('e2e-parent'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) const parent = ctx.agentLoop.create(SessionId('e2e-parent'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
parent.send([{ type: 'text', text: parent.followup([{ 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 '
+ 'SUBAGENT_WAS_HERE into a file named proof.txt in the current directory." ' + 'SUBAGENT_WAS_HERE into a file named proof.txt in the current directory." '
+ 'After the subagent finishes, tell me it is done.' }]) + 'After the subagent finishes, tell me it is done.' }])

View File

@@ -95,7 +95,7 @@ describe('dsh-subagent-spawn', () => {
it('a fresh child does NOT inherit the parent conversation (its log starts empty before the prompt)', async () => { it('a fresh child does NOT inherit the parent conversation (its log starts empty before the prompt)', async () => {
// Drive the parent through one real turn so it has history, THEN spawn. // Drive the parent through one real turn so it has history, THEN spawn.
const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('child sees nothing')]) const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('child sees nothing')])
parent.send([{ type: 'text', text: 'parent prompt' }]) parent.followup([{ type: 'text', text: 'parent prompt' }])
await parent.whenIdle() await parent.whenIdle()
const parentEventCount = parent.session.events.length const parentEventCount = parent.session.events.length
expect(parentEventCount).toBeGreaterThan(0) expect(parentEventCount).toBeGreaterThan(0)
@@ -372,7 +372,7 @@ describe('dsh-subagent-spawn', () => {
textResponse('parent answer'), textResponse('parent answer'),
textResponse('child answer'), textResponse('child answer'),
]) ])
parent.send([{ type: 'text', text: 'hi' }]) parent.followup([{ type: 'text', text: 'hi' }])
await parent.whenIdle() await parent.whenIdle()
const run = await start(ctx, 'spawn', { const run = await start(ctx, 'spawn', {

View File

@@ -23,10 +23,11 @@ function stubAgent(ctx: Context, rawId: string): Agent {
session: new Session(id), session: new Session(id),
status: 'idle' as const, status: 'idle' as const,
ctx: scopeFiber.ctx, ctx: scopeFiber.ctx,
send: () => AgentMessageId('stub'),
followup: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'),
queue: () => AgentMessageId('stub'),
steer: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'),
inject: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'),
send: () => AgentMessageId('stub'),
cancel() {}, cancel() {},
whenIdle() { return Promise.resolve() }, whenIdle() { return Promise.resolve() },
} }

View File

@@ -59,7 +59,7 @@ describe('todo_write tool through the agent loop', () => {
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-todo'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('it-todo'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'plan a two-step task' }]) agent.followup([{ type: 'text', text: 'plan a two-step task' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
const log = agent.session.events const log = agent.session.events
@@ -87,7 +87,7 @@ describe('todo_write tool through the agent loop', () => {
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-todo-2'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('it-todo-2'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'plan then update' }]) agent.followup([{ type: 'text', text: 'plan then update' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
const todoEvents = agent.session.events.filter(e => e.type === 'todo/write') const todoEvents = agent.session.events.filter(e => e.type === 'todo/write')

View File

@@ -29,7 +29,7 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
| `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | creates a new session/agent; N concurrent sessions are allowed, keyed by id; advertises the effective command snapshot; `cwd` must be absolute (it becomes the session's workspace — see Per-session cwd); non-empty `additionalDirectories` and `mcpServers` rejected | | `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | creates a new session/agent; N concurrent sessions are allowed, keyed by id; advertises the effective command snapshot; `cwd` must be absolute (it becomes the session's workspace — see Per-session cwd); non-empty `additionalDirectories` and `mcpServers` rejected |
| `session/load` | `ctx.agents.resume(...)` | reserves the id, verifies the persisted cwd, resumes, replays user, assistant, tool, and title events, and re-advertises commands | | `session/load` | `ctx.agents.resume(...)` | reserves the id, verifies the persisted cwd, resumes, replays user, assistant, tool, and title events, and re-advertises commands |
| `session/list` | `ctx.sessionQuery` | returns live-preferred newest-first sessions with absolute cwd and optional folded title; supports exact normalized cwd filtering, returns no cursor, and rejects supplied cursors | | `session/list` | `ctx.sessionQuery` | returns live-preferred newest-first sessions with absolute cwd and optional folded title; supports exact normalized cwd filtering, returns no cursor, and rejects supplied cursors |
| `session/prompt` | `ctx.commands.execute()` or `agent.send()` | a flattened prompt beginning with `/` stays in the direct command plane; ordinary prompts support ACP `text` and `resource_link`; `dsh-session:` links and inline mentions are snapshotted through optional `ctx.sessionReferences` before enqueue; unsupported content, unavailable reference capability, failed snapshots, and empty prompts are rejected; one request is in flight per session and settles on the owning turn's end, with an error turn rejecting the RPC | | `session/prompt` | `ctx.commands.execute()` or `agent.followup()` | a flattened prompt beginning with `/` stays in the direct command plane; ordinary prompts support ACP `text` and `resource_link`; `dsh-session:` links and inline mentions are snapshotted through optional `ctx.sessionReferences` before enqueue; unsupported content, unavailable reference capability, failed snapshots, and empty prompts are rejected; one request is in flight per session and settles on the owning turn's end, with an error turn rejecting the RPC |
| `session/cancel` | command `AbortSignal` or `agent.cancel()` | aborts the exact direct command, or applies the queue-aware agent cancel and settles its prompt `cancelled`; one session never cancels another | | `session/cancel` | command `AbortSignal` or `agent.cancel()` | aborts the exact direct command, or applies the queue-aware agent cancel and settles its prompt `cancelled`; one session never cancels another |
| `session/update` | `session/event` | streams user replay, assistant text/reasoning, retry/failure attempt markers, tool render intents, and `session_info_update` title revisions | | `session/update` | `session/event` | streams user replay, assistant text/reasoning, retry/failure attempt markers, tool render intents, and `session_info_update` title revisions |
| `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice | | `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice |

View File

@@ -23,7 +23,7 @@ The bridge implements the **core prompt-turn loop** for N concurrent sessions: i
| `session/load` | S | ✅ | ✅ | ✅ | Maps to `agents.resume` + full event-log replay; validates persisted `cwd` before constructing the agent. | | `session/load` | S | ✅ | ✅ | ✅ | Maps to `agents.resume` + full event-log replay; validates persisted `cwd` before constructing the agent. |
| `session/resume` | S | ❌ | ✅ | ✅ | Reconnect WITHOUT replay; gated by `sessionCapabilities.resume`. Not advertised. | | `session/resume` | S | ❌ | ✅ | ✅ | Reconnect WITHOUT replay; gated by `sessionCapabilities.resume`. Not advertised. |
| `session/close` | S | ❌ | ✅ | ✅ | No `session/close` handler — the SDK dispatch returns `method_not_found`. The bridge tears sessions down on client disconnect / Cordis disposal (cross-cutting, see [§8](#8-cross-cutting)), but that is not the on-demand per-session method. | | `session/close` | S | ❌ | ✅ | ✅ | No `session/close` handler — the SDK dispatch returns `method_not_found`. The bridge tears sessions down on client disconnect / Cordis disposal (cross-cutting, see [§8](#8-cross-cutting)), but that is not the on-demand per-session method. |
| `session/prompt` | S | ✅ | ✅ | ✅ | A flattened prompt beginning with `/` dispatches through `ctx.commands` without a model request; ordinary input maps to `agent.send`. One request is in flight per session. | | `session/prompt` | S | ✅ | ✅ | ✅ | A flattened prompt beginning with `/` dispatches through `ctx.commands` without a model request; ordinary input maps to `agent.followup`. One request is in flight per session. |
| `session/cancel` | S | ✅ | ✅ | ✅ | Aborts the exact direct command, or applies queue-aware `agent.cancel` and settles its prompt `cancelled`, scoped to one session. | | `session/cancel` | S | ✅ | ✅ | ✅ | Aborts the exact direct command, or applies queue-aware `agent.cancel` and settles its prompt `cancelled`, scoped to one session. |
| `session/set_mode` | S | ✅ | ✅ | ✅ | Composed opportunistically: with `@deepseek-ai/dsh-plan-mode` mounted, `session/new`/`session/load` advertise the fixed `default` / `plan` projection and `session/set_mode` records the boolean pending intent (optimistic `current_mode_update`; logged `plan/mode` lands at the turn boundary). Without the plugin: no `modes` advertised, `set_mode` rejected (see [§6 Modes](#6-session-modes--config-options--models)). | | `session/set_mode` | S | ✅ | ✅ | ✅ | Composed opportunistically: with `@deepseek-ai/dsh-plan-mode` mounted, `session/new`/`session/load` advertise the fixed `default` / `plan` projection and `session/set_mode` records the boolean pending intent (optimistic `current_mode_update`; logged `plan/mode` lands at the turn boundary). Without the plugin: no `modes` advertised, `set_mode` rejected (see [§6 Modes](#6-session-modes--config-options--models)). |
| `session/set_config_option` | S | ✅ | ✅ | ✅ | A provider/model select is present for a complete registered target; one `permission` select is added when `ctx.permission` is composed. Every response carries the complete refreshed state. | | `session/set_config_option` | S | ✅ | ✅ | ✅ | A provider/model select is present for a complete registered target; one `permission` select is added when `ctx.permission` is composed. Every response carries the complete refreshed state. |

View File

@@ -264,7 +264,7 @@ describe('acp bridge — disposal & HMR safety', () => {
const handle = await harness.ctx.agents.create({ const handle = await harness.ctx.agents.create({
sessionId: SessionId('guard-a'), agentOptions: { provider: 'mock', model: 'mock' }, sessionId: SessionId('guard-a'), agentOptions: { provider: 'mock', model: 'mock' },
}) })
handle.agent.send([{ type: 'text', text: 'go' }]) handle.agent.followup([{ type: 'text', text: 'go' }])
await handle.agent.whenIdle() await handle.agent.whenIdle()
expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeDefined() expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeDefined()
@@ -288,7 +288,7 @@ describe('acp bridge — disposal & HMR safety', () => {
// 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
// teardown observably in-flight. // teardown observably in-flight.
handle.agent.send([{ type: 'text', text: 'go' }]) handle.agent.followup([{ type: 'text', text: 'go' }])
await new Promise(r => setTimeout(r, 30)) await new Promise(r => setTimeout(r, 30))
expect(handle.agent.status).toBe('running') expect(handle.agent.status).toBe('running')
let releaseFlush!: () => void let releaseFlush!: () => void

View File

@@ -30,7 +30,7 @@ describe('acp bridge — demux & config edges', () => {
const before = harness.updates.length const before = harness.updates.length
const { agent: foreign } = await harness.ctx.agents.create({ sessionId: SessionId('foreign-session'), agentOptions: { provider: 'mock', model: 'mock' } }) const { agent: foreign } = await harness.ctx.agents.create({ sessionId: SessionId('foreign-session'), agentOptions: { provider: 'mock', model: 'mock' } })
foreign.send([{ type: 'text', text: 'hi' }]) foreign.followup([{ type: 'text', text: 'hi' }])
await foreign.whenIdle() await foreign.whenIdle()
await new Promise(r => setTimeout(r, 10)) await new Promise(r => setTimeout(r, 10))

View File

@@ -152,7 +152,7 @@ export class HarnessSdkServer {
rec.activePrompt = true rec.activePrompt = true
try { try {
rec.lastTurnEnd = undefined rec.lastTurnEnd = undefined
rec.handle.agent.send(params.contentBlocks) rec.handle.agent.followup(params.contentBlocks)
await rec.handle.agent.whenIdle() await rec.handle.agent.whenIdle()
const status = this.finishedStatus(rec.lastTurnEnd) const status = this.finishedStatus(rec.lastTurnEnd)
this.transport.notify('session.finished', { this.transport.notify('session.finished', {

View File

@@ -5,7 +5,7 @@ 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 { type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent' import { AgentMessageId, type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
@@ -152,7 +152,7 @@ describe('HarnessSdkServer', () => {
meta: { cwd: storageDir }, meta: { cwd: storageDir },
agentOptions: { provider: 'deepseek', model: 'dsagent-model' }, agentOptions: { provider: 'deepseek', model: 'dsagent-model' },
}) })
orphanHandle.agent.send([{ type: 'text', text: 'outside the sdk session map' }]) orphanHandle.agent.followup([{ type: 'text', text: 'outside the sdk session map' }])
await orphanHandle.agent.whenIdle() await orphanHandle.agent.whenIdle()
await orphanHandle.dispose() await orphanHandle.dispose()
expect(llmServer.requests).toHaveLength(3) expect(llmServer.requests).toHaveLength(3)
@@ -170,16 +170,16 @@ describe('HarnessSdkServer', () => {
const mainWhenIdle = vi.fn<() => Promise<void>>() const mainWhenIdle = vi.fn<() => Promise<void>>()
.mockReturnValueOnce(firstMainIdle) .mockReturnValueOnce(firstMainIdle)
.mockResolvedValue(undefined) .mockResolvedValue(undefined)
const mainSend = vi.fn() const mainFollowup = vi.fn<Agent['followup']>().mockReturnValue(AgentMessageId('main-followup'))
const mainAgent = { const mainAgent = ({
send: mainSend, followup: mainFollowup,
whenIdle: mainWhenIdle, whenIdle: mainWhenIdle,
} as unknown as Agent } satisfies Pick<Agent, 'followup' | 'whenIdle'>) as unknown as Agent
const otherSend = vi.fn() const otherFollowup = vi.fn<Agent['followup']>().mockReturnValue(AgentMessageId('other-followup'))
const otherAgent = { const otherAgent = ({
send: otherSend, followup: otherFollowup,
whenIdle: vi.fn(() => Promise.resolve()), whenIdle: vi.fn(() => Promise.resolve()),
} as unknown as Agent } satisfies Pick<Agent, 'followup' | 'whenIdle'>) 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: { sessionId: SessionId }) => const create = vi.fn(async (options: { sessionId: SessionId }) =>
@@ -196,7 +196,7 @@ describe('HarnessSdkServer', () => {
}) })
const first = prompt('main', 'first') const first = prompt('main', 'first')
await vi.waitFor(() => { expect(mainSend).toHaveBeenCalledOnce() }) await vi.waitFor(() => { expect(mainFollowup).toHaveBeenCalledOnce() })
await expect(prompt('main', 'overlap')).rejects.toThrow('session already has an active prompt: main') await expect(prompt('main', 'overlap')).rejects.toThrow('session already has an active prompt: main')
await expect(prompt('other', 'independent')).resolves.toEqual({ accepted: true }) await expect(prompt('other', 'independent')).resolves.toEqual({ accepted: true })
@@ -208,8 +208,8 @@ describe('HarnessSdkServer', () => {
await expect(prompt('main', 'failing')).rejects.toThrow('turn wait failed') await expect(prompt('main', 'failing')).rejects.toThrow('turn wait failed')
await expect(prompt('main', 'after failure')).resolves.toEqual({ accepted: true }) await expect(prompt('main', 'after failure')).resolves.toEqual({ accepted: true })
expect(mainSend).toHaveBeenCalledTimes(4) expect(mainFollowup).toHaveBeenCalledTimes(4)
expect(otherSend).toHaveBeenCalledOnce() expect(otherFollowup).toHaveBeenCalledOnce()
await server.shutdown() await server.shutdown()
expect(mainHandle.dispose).toHaveBeenCalledOnce() expect(mainHandle.dispose).toHaveBeenCalledOnce()
expect(otherHandle.dispose).toHaveBeenCalledOnce() expect(otherHandle.dispose).toHaveBeenCalledOnce()
@@ -225,9 +225,9 @@ describe('HarnessSdkServer', () => {
shutdown(): Promise<Record<string, never>> shutdown(): Promise<Record<string, never>>
} }
const session = ctx.sessions.create(SessionId('message-outcome')) const session = ctx.sessions.create(SessionId('message-outcome'))
const agent = { const agent = ({
session, session,
send(content: { type: 'text'; text: string }[]) { followup(content: { type: 'text'; text: string }[]) {
session.append('turn/start', { session.append('turn/start', {
turn: 1, turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } }, trigger: { kind: 'message', source: { kind: 'user' } },
@@ -246,9 +246,10 @@ describe('HarnessSdkServer', () => {
source: { kind: 'plugin', plugin: 'late-metadata' }, source: { kind: 'plugin', plugin: 'late-metadata' },
}, { surfaceOp: 'append' }) }, { surfaceOp: 'append' })
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
return AgentMessageId('message-outcome')
}, },
whenIdle: () => Promise.resolve(), whenIdle: () => Promise.resolve(),
} as unknown as Agent } satisfies Pick<Agent, 'session' | 'followup' | 'whenIdle'>) as unknown as Agent
server.sessions.set('message-outcome', { server.sessions.set('message-outcome', {
handle: { agent, dispose: () => Promise.resolve() }, handle: { agent, dispose: () => Promise.resolve() },
lastTurnEnd: undefined, lastTurnEnd: undefined,

View File

@@ -18,9 +18,9 @@ Before model output, session events, tool presenters, questions, configuration,
Typing `@` at a token boundary searches files and directories under the session working directory. A bare fuzzy query uses a reusable bounded workspace index; a query containing `/` lists that directory directly, and selecting a folder keeps completion open for descent. Whitespace-bearing paths are inserted as `@"path with spaces"`. Selecting a file inserts only its path and a trailing space: the TUI does not read it, attach hidden context, or replace it with a reference object. When a model-facing `read` tool is registered, the TUI adds one fixed system-prompt instruction telling the model to read an explicit path when its contents are needed. Typing `@` at a token boundary searches files and directories under the session working directory. A bare fuzzy query uses a reusable bounded workspace index; a query containing `/` lists that directory directly, and selecting a folder keeps completion open for descent. Whitespace-bearing paths are inserted as `@"path with spaces"`. Selecting a file inserts only its path and a trailing space: the TUI does not read it, attach hidden context, or replace it with a reference object. When a model-facing `read` tool is registered, the TUI adds one fixed system-prompt instruction telling the model to read an explicit path when its contents are needed.
When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:<payload>)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.send()` from the status after that asynchronous preparation, so idle sends still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook. When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:<payload>)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.followup()` from the status after that asynchronous preparation, so idle follow-ups still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook.
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/reasoning`, `/tools`, `/redraw`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/reasoning`, `/tools`, `/redraw`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Enter selects, and Escape closes it. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same pair through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local. `/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Enter selects, and Escape closes it. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same pair through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local.
@@ -77,7 +77,7 @@ The palette uses the standard 16-color ANSI foregrounds and SGR attributes, whic
#### What the model sees #### What the model sees
Each non-empty ordinary editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. A session mention becomes readable `@label` text plus the durable untrusted context defined by [`dsh-session-reference`](../../context/session-reference/README.md); its full JSON is hidden behind a compact reference card. Slash commands and keybindings are TUI-only; command results remain terminal notices. A command producer may schedule a separate agent input, such as the optional message accepted by `/plan [message]`. Each non-empty ordinary editor submission becomes one text block, sent with `agent.followup()` while the target agent is idle and `agent.steer()` while it is running. A session mention becomes readable `@label` text plus the durable untrusted context defined by [`dsh-session-reference`](../../context/session-reference/README.md); its full JSON is hidden behind a compact reference card. Slash commands and keybindings are TUI-only; command results remain terminal notices. A command producer may schedule a separate agent input, such as the optional message accepted by `/plan [message]`.
#### Token effect #### Token effect
@@ -125,7 +125,7 @@ Changing provider or model enters that target's cache domain; no cache reuse acr
#### What the model sees #### What the model sees
A `/skill:<name> [instructions]` submission loads the named skill and delivers one text block: a `<skill name="…">` element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same send-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill; model-disabled skills are omitted from autocomplete but stay loadable by exact name. A `/skill:<name> [instructions]` submission loads the named skill and delivers one text block: a `<skill name="…">` element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same followup-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill; model-disabled skills are omitted from autocomplete but stay loadable by exact name.
#### Token effect #### Token effect

View File

@@ -2474,7 +2474,7 @@ describe('terminal mounting', () => {
const session = ctx.sessions.create(SessionId('main')) const session = ctx.sessions.create(SessionId('main'))
ctx.agents.register({ ctx.agents.register({
id: session.id, options: {}, session, status: 'idle', ctx, id: session.id, options: {}, session, status: 'idle', ctx,
send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
}) })
const terminal = new FakeTerminal() const terminal = new FakeTerminal()
mountTui(ctx, { color: false }, { terminal, exit: vi.fn() }) mountTui(ctx, { color: false }, { terminal, exit: vi.fn() })
@@ -2498,7 +2498,7 @@ describe('terminal mounting', () => {
const session = ctx.sessions.create(SessionId('main')) const session = ctx.sessions.create(SessionId('main'))
ctx.agents.register({ ctx.agents.register({
id: session.id, options: {}, session, status: 'idle', ctx, id: session.id, options: {}, session, status: 'idle', ctx,
send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
}) })
const terminal = new FakeTerminal() const terminal = new FakeTerminal()
// Mirror dsh-tui's own inject (minus loader, the absence under test). // Mirror dsh-tui's own inject (minus loader, the absence under test).
@@ -2532,14 +2532,14 @@ describe('terminal mounting', () => {
const otherSession = ctx.sessions.create(SessionId('other-session')) const otherSession = ctx.sessions.create(SessionId('other-session'))
ctx.agents.register({ ctx.agents.register({
id: otherSession.id, options: {}, session: otherSession, status: 'idle', ctx, id: otherSession.id, options: {}, session: otherSession, status: 'idle', ctx,
send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
}) })
expect(terminal.started).toBe(0) expect(terminal.started).toBe(0)
const session = ctx.sessions.create(SessionId('late-session')) const session = ctx.sessions.create(SessionId('late-session'))
const agent = { const agent = {
id: session.id, options: {}, session, status: 'idle', ctx, id: session.id, options: {}, session, status: 'idle', ctx,
send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
} as Agent } as Agent
ctx.agents.register(agent) ctx.agents.register(agent)
await tick() await tick()
@@ -2569,7 +2569,7 @@ describe('terminal mounting', () => {
const session = ctx.sessions.create(SessionId('main-session')) const session = ctx.sessions.create(SessionId('main-session'))
ctx.agents.register({ ctx.agents.register({
id: session.id, options: {}, session, status: 'idle', ctx, id: session.id, options: {}, session, status: 'idle', ctx,
send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
}) })
await tick() await tick()
expect(terminal.started).toBe(0) expect(terminal.started).toBe(0)
@@ -2611,7 +2611,7 @@ describe('terminal mounting', () => {
session.append('step/start', { turn: 1, step: 1 }) session.append('step/start', { turn: 1, step: 1 })
ctx.agents.register({ ctx.agents.register({
id: session.id, options: {}, session, status: 'running', ctx, id: session.id, options: {}, session, status: 'running', ctx,
send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
}) })
const terminal = new FakeTerminal() const terminal = new FakeTerminal()
terminal.start = () => { throw new Error('terminal startup failed') } terminal.start = () => { throw new Error('terminal startup failed') }

View File

@@ -70,7 +70,7 @@ describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => {
agentOptions: { provider: 'mock', model: 'mock' }, agentOptions: { provider: 'mock', model: 'mock' },
}) })
const parent = parentHandle.agent const parent = parentHandle.agent
parent.send([{ type: 'text', text: 'PARENT_PROMPT_MARKER' }]) parent.followup([{ type: 'text', text: 'PARENT_PROMPT_MARKER' }])
await parent.whenIdle() await parent.whenIdle()
const children: Agent[] = [] const children: Agent[] = []

View File

@@ -918,7 +918,7 @@ function renderLifecycle(): string {
' participant Session', ' participant Session',
' participant Persistence', ' participant Persistence',
' participant SDK as UI or SDK listener', ' participant SDK as UI or SDK listener',
' User->>Agent: send(content)', ' User->>Agent: followup(content)',
` Agent-->>SDK: ${mermaidCode('agent/inbox/enqueue')}`, ` Agent-->>SDK: ${mermaidCode('agent/inbox/enqueue')}`,
' Agent->>Driver: queued work wakes driver', ' Agent->>Driver: queued work wakes driver',
` Driver-->>SDK: ${mermaidCode('agent/status')} running`, ` Driver-->>SDK: ${mermaidCode('agent/status')} running`,