From 0129063ae7d45e0eedbee9b2055b98cfb13e5d95 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:22:10 +0800 Subject: [PATCH 01/25] feat(goal): add model-facing goal tools --- docs/config-catalog.md | 14 + docs/module-graph.md | 8 + docs/rfc/INDEX.md | 1 + ...26-07-19-model-facing-goal-tools.i18n.yaml | 6 + .../2026-07-19-model-facing-goal-tools.md | 63 +++ .../2026-07-19-model-facing-goal-tools.zh.md | 63 +++ docs/tool-catalog.md | 89 +++++ .../tests/fixtures/goal/tool-goal/cordis.yml | 26 ++ .../fixtures/goal/tool-goal/scripted-llm.ts | 88 +++++ examples/package.json | 1 + knip.json | 5 + .../core/tools/tests/gen-tool-catalog.spec.ts | 2 +- packages/goal/README.md | 1 + packages/goal/tool-goal/README.md | 55 +++ packages/goal/tool-goal/package.json | 47 +++ packages/goal/tool-goal/src/authority.ts | 105 +++++ packages/goal/tool-goal/src/index.ts | 222 +++++++++++ .../goal/tool-goal/tests/tool-goal.e2e.ts | 124 ++++++ .../goal/tool-goal/tests/tool-goal.spec.ts | 374 ++++++++++++++++++ packages/goal/tool-goal/tsconfig.json | 39 ++ pnpm-lock.yaml | 37 ++ scripts/gen-tool-catalog.ts | 17 + tsconfig.build.json | 1 + tsconfig.json | 1 + 24 files changed, 1388 insertions(+), 1 deletion(-) create mode 100644 docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md create mode 100644 docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md create mode 100644 examples/echo-agent/tests/fixtures/goal/tool-goal/cordis.yml create mode 100644 examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts create mode 100644 packages/goal/tool-goal/README.md create mode 100644 packages/goal/tool-goal/package.json create mode 100644 packages/goal/tool-goal/src/authority.ts create mode 100644 packages/goal/tool-goal/src/index.ts create mode 100644 packages/goal/tool-goal/tests/tool-goal.e2e.ts create mode 100644 packages/goal/tool-goal/tests/tool-goal.spec.ts create mode 100644 packages/goal/tool-goal/tsconfig.json diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6473848ab3..8f95bad05b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1098,6 +1098,20 @@ export interface Config { Source: [`packages/fs/tool-fs-search/src/index.ts:59`](../packages/fs/tool-fs-search/src/index.ts) +## `@deepseek-ai/dsh-tool-goal` + +Requires: `agents` · `goals` · `tools` · `systemPrompt` + +```ts config-catalog +/** Model policy and hard lower bounds for goal-state updates. */ +export interface Config { + /** Minimum admitted goal rounds before the model may self-report `blocked`. */ + blockedAfterConsecutiveRounds?: number +} +``` + +Source: [`packages/goal/tool-goal/src/index.ts:25`](../packages/goal/tool-goal/src/index.ts) + ## `@deepseek-ai/dsh-tool-skill` Requires: `tools` · `skills` diff --git a/docs/module-graph.md b/docs/module-graph.md index 56934f8495..23879ed342 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -30,6 +30,7 @@ flowchart TD end subgraph group_goal["packages/goal"] pkg_goal["goal"] + pkg_tool_goal["tool-goal"] end subgraph group_bash["packages/bash"] pkg_bash["bash"] @@ -261,6 +262,12 @@ flowchart TD pkg_agent_loop --> pkg_session_persistence pkg_agent_loop --> pkg_system_prompt pkg_agent_loop --> pkg_tools + pkg_tool_goal --> pkg_agent + pkg_tool_goal --> pkg_goal + pkg_tool_goal --> pkg_llm + pkg_tool_goal --> pkg_session + pkg_tool_goal --> pkg_system_prompt + pkg_tool_goal --> pkg_tools pkg_tool_bash --> pkg_agent pkg_tool_bash --> pkg_bash pkg_tool_bash --> pkg_home @@ -511,6 +518,7 @@ flowchart TD | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`home`](../packages/util/home), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index e6d4ef306b..d3c408f214 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -89,6 +89,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Optional time-context plugin](implemented/feature/2026-07-14-time-context-plugin.md) | 2026-07-14 | | [Durable per-step time context](implemented/feature/2026-07-16-durable-per-step-time-context.md) | 2026-07-16 | | [Dedicated full-screen TUI front door](implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) | 2026-07-17 | +| [Model-facing same-session goal tools](implemented/feature/2026-07-19-model-facing-goal-tools.md) | 2026-07-19 | | [Persisted same-session goal domain](implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) | 2026-07-19 | ### Simplification diff --git a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml new file mode 100644 index 0000000000..0d66c0c360 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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: +# pnpm run verify-translation-pairing --write +2026-07-19-model-facing-goal-tools.md: 7a207bbc73e13ca5111d73ee2c58cf05fbe6387e +2026-07-19-model-facing-goal-tools.zh.md: 46ad7671e1ef2a6856df6d1ec893896aab9760b5 diff --git a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md new file mode 100644 index 0000000000..7a207bbc73 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md @@ -0,0 +1,63 @@ +# RFC: Model-facing same-session goal tools + +Status: implemented + +English | [中文](2026-07-19-model-facing-goal-tools.zh.md) + +## Problem + +The persisted goal domain deliberately exposes lifecycle verbs to plugins, not directly to a model. A model still needs a small control surface for discovering the current goal, creating one from human intent, and changing its lifecycle. Prompt guidance alone cannot establish who authorized a mutation: a subagent, injected plugin message, stale model turn, or resumed session could all produce the same tool arguments. + +The surface also needs to preserve the separation between durable state and live execution authority. A restored or forked session can replay an active goal but starts disarmed; a later human request such as “continue” should let the model rearm it without requiring a literal command phrase. Conversely, an admitted autonomous goal round must be able to report completion or a persistent blocker without gaining permission to edit, pause, resume, or replace the human objective. + +## Decision + +`@deepseek-ai/dsh-tool-goal` in `packages/goal/tool-goal/` contributes three exclusive tools and one system-prompt policy section over `ctx.goals`: `get_goal`, `create_goal`, and `update_goal`. The names and read-create-update shape follow Codex's compact goal tool surface while the authority rules use this repository's public agent, session, tool, and goal seams. + +### Tools and model contract + +`get_goal()` returns the current goal or `null`. A non-null result contains the compare-and-set id and revision, objective, durable phase, admitted and maximum goal rounds, plus the process-local activation observation. `create_goal(objective, max_goal_rounds?)` creates one long-running same-session objective. `update_goal(goal_id, revision, action, objective?, max_goal_rounds?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`; replacement fields are valid only for `edit`. + +The prompt tells the model that it may infer goal intent from a direct human request in any wording or language, but should not convert routine single-turn work into a goal. It must read the current goal before updating and copy the exact id and revision. On a restored or forked active-but-disarmed goal, a semantic human request to continue is grounds for `resume`. Completion is reserved for an achieved objective, and difficulty or uncertainty alone is not a blocker. + +All three tools use exclusive execution so a model-ordered batch observes prior mutations and their new revisions. Results are compact JSON. ACP presentation is a pure function of arguments and uses generic read or mutation cards; activation is reported only as live observation and is never written into replay state. + +### Execution authority + +Every call requires an `exec.agent` that is the exact running object in `AgentRegistry`, is the current inherited driver initiator, and has an open turn. These are execution-time checks and cannot be bypassed by prompt injection or hand-authored tool arguments. + +Create, edit, pause, and resume additionally require an accepted user message or user steering event in the current turn of a runtime-root agent. Root ownership is derived from the live agent graph rather than durable fork ancestry: a resumed fork can receive direct human authority, while a live child remains a subagent and cannot mutate these states. The runtime proves provenance, not whether the human's wording semantically warrants creation or resumption; that interpretation remains with the model. + +Complete and blocked accept either direct-human authority or the exact current goal round. Goal-round authority requires a goal-sourced `user/message` whose goal id, revision, and round all equal the folded current goal. It grants only the two terminal reports. Direct human authority may stop a goal immediately. + +### Blocking threshold + +`blockedAfterConsecutiveRounds` is a validated positive safe-integer configuration with default `3`. When an autonomous goal round calls `blocked`, the plugin mechanically requires at least that many admitted rounds; the configured value also appears in model guidance. The runtime cannot determine whether those rounds encountered the same blocking condition, so semantic equivalence remains a model judgment. This count is deliberately separate from the goal's generous continuation cap. + +## Testing + +Unit coverage pins registration and disposal, exclusive scheduling, generated prompt policy, generic presentation, direct-human creation in a non-English turn, exact live-agent and driver checks, root-versus-child authority, steering, mismatched initiators, read/create/edit/pause/resume behavior, rearming after a session-start edge, compare-and-set and argument failures, exact goal-round completion, the configured blocking threshold, and immediate human blocking. A keyless Loader/stdio process test mounts the real goal, tool, loop, and persistence plugins through `cordis.yml`, drives scripted model tool calls, and reads the JSONL externally to verify the model-visible create/pause snapshots, structured tool results, and configured prompt text. + +## Alternatives considered + +- **Rely on prompt instructions for authority** — rejected because text can guide model judgment but cannot authenticate the live caller, turn, or source event. +- **Expose every goal-service verb as a separate tool** — rejected because a compact read/create/update surface reduces schema cost and keeps compare-and-set behavior uniform. +- **Require exact command phrases** — rejected because natural-language intent, including languages other than English, should be interpreted by the model; execution authority depends on provenance rather than spelling. +- **Authorize from persisted root or fork metadata** — rejected because a fork that becomes an independently resumed top-level session should accept new human authority, while a currently owned child should not. +- **Let autonomous rounds edit or resume the goal** — rejected because continuation authority is narrower than authority to redefine or restart the human objective. +- **Treat the blocked threshold as an evaluator** — rejected because event counts cannot prove that an obstacle is semantically unchanged or truly terminal. + +## Consequences + +- Models receive a stable, compact lifecycle surface without direct access to the goal service. +- State-changing calls are constrained by live runtime provenance as well as durable compare-and-set references. +- Human requests can create and rearm goals through ordinary natural language, while restored sessions remain inert until such input arrives. +- Goal rounds can finish or report a repeated blocker but cannot broaden their own mandate. +- Deployment policy selects the blocking lower bound; the same resolved value controls enforcement and prompt guidance. + +## Known limitations and deferred work + +- Semantic classification of a substantial goal, a request to continue, objective completion, and the same blocking condition remains model judgment. An independent evaluator or completion certificate is deferred. +- These tools mutate goal state but do not schedule goal rounds, classify abnormal driver stops, or cancel an active turn; the same-session driver owns those behaviors. +- Human slash-command discovery and rendering are deferred to the command-surface layer. +- A scope can hide tool registrations while leaving the independently registered prompt section visible unless the deployment scopes both together. diff --git a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md new file mode 100644 index 0000000000..46ad7671e1 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md @@ -0,0 +1,63 @@ +# RFC:面向模型的同会话目标工具 + +Status: implemented + +[English](2026-07-19-model-facing-goal-tools.md) | 中文 + +## 问题 + +持久目标领域有意把生命周期动词提供给插件,而不直接提供给模型。模型仍然需要一个小型控制面,用于发现当前目标、根据人类意图创建目标并改变其生命周期。仅靠提示词指导无法确定是谁授权了一次变更:子智能体、注入的插件消息、陈旧的模型轮次或恢复后的会话都可能产生相同的工具参数。 + +该表面还需要保持持久状态与实时执行权限之间的分离。恢复或 fork(派生)后的会话可以回放活跃目标,但初始处于未激活状态;后续人类提出“继续”之类的请求时,模型应能重新激活目标,而无需用户使用字面命令。相反,已接纳的自主目标回合必须能够报告完成或持续阻塞,却不能因此获得编辑、暂停、恢复或替换人类目标的权限。 + +## 决策 + +位于 `packages/goal/tool-goal/` 的 `@deepseek-ai/dsh-tool-goal` 在 `ctx.goals` 之上贡献三个独占工具和一个系统提示词策略段:`get_goal`、`create_goal` 与 `update_goal`。工具名称和读取—创建—更新形态遵循 Codex 的紧凑目标工具表面,而权限规则使用本仓库公共的 agent(智能体)、会话、工具与目标接缝。 + +### 工具与模型契约 + +`get_goal()` 返回当前目标或 `null`。非空结果包含用于比较并交换的 id 与修订号、目标描述、持久阶段、已接纳和最大目标回合数,以及进程本地激活态观察。`create_goal(objective, max_goal_rounds?)` 创建一个长时间运行的同会话目标。`update_goal(goal_id, revision, action, objective?, max_goal_rounds?)` 支持 `edit`、`pause`、`resume`、`complete` 和 `blocked`;替换字段仅对 `edit` 有效。 + +提示词告诉模型:它可以从任何措辞或语言的直接人类请求中推断目标意图,但不应把常规单轮工作转换为目标。更新前必须读取当前目标,并复制准确的 id 和修订号。对于恢复或派生后处于活跃但未激活状态的目标,人类在语义上要求继续即可成为执行 `resume` 的依据。只有目标已经实现时才能标记完成,困难或不确定性本身不构成阻塞。 + +三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。ACP 展示是参数的纯函数,使用通用读取或变更卡片;激活态仅作为实时观察返回,绝不会写入回放状态。 + +### 执行权限 + +每次调用都要求存在 `exec.agent`,且它必须是 `AgentRegistry` 中完全相同的运行中对象、当前继承的驱动发起者,并处于开放轮次内。这些检查在执行时进行,不能通过提示词注入或手写工具参数绕过。 + +创建、编辑、暂停与恢复还要求运行时根智能体的当前轮次已经接纳一条用户消息或用户 steering(转向)事件。根所有权来自实时智能体图,而非持久的 fork 祖先关系:恢复后的派生会话可以接收新的直接人类权限,实时子智能体则仍然是子智能体,不能改变这些状态。运行时证明来源,而不判断人类措辞在语义上是否足以创建或恢复目标;该解释仍由模型完成。 + +完成与阻塞既接受直接人类权限,也接受准确的当前目标回合。目标回合权限要求存在一条来源为目标的 `user/message`,其中目标 id、修订号和回合都与折叠后的当前目标相等。它只授予这两种终止报告权限。直接人类权限可以立即停止目标。 + +### 阻塞阈值 + +`blockedAfterConsecutiveRounds` 是经过校验的正安全整数配置,默认值为 `3`。自主目标回合调用 `blocked` 时,插件会机械地要求至少已经接纳该数量的回合;配置值也会出现在模型指导中。运行时无法判断这些回合是否遇到了语义上相同的阻塞条件,因此语义等价性仍由模型判断。该计数特意与目标的宽裕继续执行上限分离。 + +## 测试 + +单元测试固定注册与释放、独占调度、生成的提示词策略、通用展示、非英语轮次中的直接人类创建、精确实时智能体与驱动检查、根与子智能体权限、steering、发起者不匹配、读取/创建/编辑/暂停/恢复行为、会话启动边沿后的重新激活、比较并交换与参数失败、准确目标回合的完成、可配置阻塞阈值,以及人类立即阻塞。无密钥 Loader/stdio 进程测试通过 `cordis.yml` 挂载真实的目标、工具、循环和持久化插件,驱动脚本化模型工具调用,并从外部读取 JSONL,以验证模型可见的创建/暂停快照、结构化工具结果和配置后的提示词文本。 + +## 考虑过的替代方案 + +- **依赖提示词指令实施权限**——不予采纳,因为文本可以指导模型判断,却不能认证实时调用者、轮次或来源事件。 +- **把每个目标服务动词分别暴露为工具**——不予采纳,因为紧凑的读取/创建/更新表面可以降低模式成本,并保持统一的比较并交换行为。 +- **要求精确命令短语**——不予采纳,因为自然语言意图(包括英语以外的语言)应由模型解释;执行权限取决于来源,而不是拼写。 +- **根据持久的根或派生元数据授权**——不予采纳,因为成为独立恢复顶层会话的派生应接受新的人类权限,而当前仍受所有权约束的子智能体则不应接受。 +- **允许自主回合编辑或恢复目标**——不予采纳,因为继续执行权限比重新定义或重启人类目标的权限更窄。 +- **把阻塞阈值当作评估器**——不予采纳,因为事件计数无法证明障碍在语义上未改变或确实不可继续。 + +## 后果 + +- 模型获得稳定而紧凑的生命周期表面,无需直接访问目标服务。 +- 改变状态的调用同时受到实时运行时来源与持久比较并交换引用的约束。 +- 人类可以通过普通自然语言请求创建和重新激活目标,而恢复后的会话在收到此类输入前保持静止。 +- 目标回合可以完成或报告重复阻塞,但不能自行扩大任务权限。 +- 部署策略选择阻塞下限;同一个解析后的值同时控制执行与提示词指导。 + +## 已知限制与延期工作 + +- 是否属于重大目标、是否要求继续、目标是否完成以及阻塞条件是否相同,仍由模型进行语义分类。独立评估器或完成证书予以延期。 +- 这些工具会改变目标状态,但不调度目标回合、不分类异常驱动停止,也不取消活跃轮次;这些行为由同会话驱动器负责。 +- 面向人类的斜杠命令发现与渲染延期到命令表面层。 +- 若部署没有同时设定两个注册项的作用域,某个作用域可能隐藏工具注册,却保留独立注册的提示词段。 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index dfb97deef3..64fd5e692d 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -21,6 +21,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `live plugin-tree mutations (mount/unmount)` | - | Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are bash-backed discovery tools: they run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | +| `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `context/message goal snapshot for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/repl-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. | | `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: a background bash command and a background subagent are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | @@ -393,6 +394,94 @@ Source: [`packages/fs/tool-fs-search/src/index.ts`](../packages/fs/tool-fs-searc glob and grep are bash-backed discovery tools: they run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. +## `@deepseek-ai/dsh-tool-goal` + +### `create_goal` + +Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. + +```json +{ + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer cap; omission uses the goal-domain deployment default." + } + }, + "required": [ + "objective" + ] +} +``` + +Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/index.ts) + +### `get_goal` + +Read the current same-session goal, including its exact id/revision, durable phase, admitted round count, cap, and live process-local activation. Call this before updating a goal. + +```json +{ + "type": "object", + "properties": {} +} +``` + +Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/index.ts) + +### `update_goal` + +Update the exact current goal revision. edit, pause, and resume require a direct top-level human turn. complete and blocked additionally accept the exact admitted goal round. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds. + +```json +{ + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] +} +``` + +Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/index.ts) + +create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. + ## `@deepseek-ai/dsh-tool-skill` ### `skill` diff --git a/examples/echo-agent/tests/fixtures/goal/tool-goal/cordis.yml b/examples/echo-agent/tests/fixtures/goal/tool-goal/cordis.yml new file mode 100644 index 0000000000..fa63160399 --- /dev/null +++ b/examples/echo-agent/tests/fixtures/goal/tool-goal/cordis.yml @@ -0,0 +1,26 @@ +# Test-only composition: drive all three goal tools through a real root agent. +- id: scripted-llm + name: './scripted-llm.ts' + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + +- id: goal + name: '@deepseek-ai/dsh-goal' + config: + defaultMaxGoalRounds: 11 + +- id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + config: + blockedAfterConsecutiveRounds: 3 + +- id: stdio-agent + name: '@deepseek-ai/dsh-stdio-demo' + config: + provider: goal-script + model: goal-script + persona: 'Execute the deterministic goal-tool composition test.' + welcome: 'goal-tools e2e ready.' + persistenceRoot: './.sessions' + workspaceContext: false diff --git a/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts b/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts new file mode 100644 index 0000000000..39498f8e39 --- /dev/null +++ b/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts @@ -0,0 +1,88 @@ +/** Deterministic adapter that creates, reads, then pauses one goal. */ + +import type { Context } from 'cordis' +import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, Message, StreamChunk } from '@deepseek-ai/dsh-llm' + +interface GoalState { + readonly id: string + readonly revision: number +} + +/** Text from the latest ordinary user message, excluding raw goal-state context. */ +function latestPrompt(messages: readonly Message[]): { index: number; text: string } { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index] + if (message?.role !== 'user') continue + const text = message.content + .filter(block => block.type === 'text' && !block.text.startsWith('')) + .map(block => block.type === 'text' ? block.text : '') + .join('\n') + if (text.length > 0) return { index, text } + } + return { index: -1, text: '' } +} + +/** Parse the latest domain snapshot rendered into history. */ +function latestGoal(messages: readonly Message[]): GoalState | undefined { + for (const message of [...messages].reverse()) { + for (const block of [...message.content].reverse()) { + if (block.type !== 'text' || !block.text.startsWith('')) continue + const json = block.text.slice(''.length, -''.length) + const value = JSON.parse(json) as { goal?: GoalState } + if (value.goal !== undefined) return value.goal + } + } + return undefined +} + +/** Names of tool calls recorded after the latest ordinary prompt. */ +function callsAfter(messages: readonly Message[], index: number): string[] { + return messages.slice(index + 1).flatMap(message => message.content) + .filter(block => block.type === 'tool-call') + .map(block => block.type === 'tool-call' ? block.name : '') +} + +/** Emit one tool-call response. */ +async function* toolCall(name: string, args: object): AsyncIterable { + const id = CallId(`call-${name}`) + const raw = JSON.stringify(args) + yield { type: 'block-start', index: 0, blockType: 'tool-call' } + yield { type: 'tool-call-delta', index: 0, id, name, argumentsDelta: raw } + yield { type: 'block-end', index: 0, block: { type: 'tool-call', id, name, arguments: raw } } + yield { type: 'finish', reason: { kind: 'tool-calls' } } +} + +/** Emit one terminal text response. */ +async function* textReply(text: string): AsyncIterable { + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text } + yield { type: 'block-end', index: 0, block: { type: 'text', text } } + yield { type: 'finish', reason: { kind: 'stop' } } +} + +class GoalScriptAdapter extends LlmAdapter { + override stream(options: GenerateOptions): AsyncIterable { + const prompt = latestPrompt(options.messages) + const calls = callsAfter(options.messages, prompt.index) + if (prompt.text === 'start' && !calls.includes('create_goal')) { + return toolCall('create_goal', { objective: 'Finish the composed goal-tool proof', max_goal_rounds: 7 }) + } + if (prompt.text === 'start' && !calls.includes('get_goal')) return toolCall('get_goal', {}) + if (prompt.text === 'start') return textReply('GOAL CREATED') + if (prompt.text === 'pause' && !calls.includes('update_goal')) { + const goal = latestGoal(options.messages) + if (goal === undefined) throw new Error('scripted goal state missing') + return toolCall('update_goal', { goal_id: goal.id, revision: goal.revision, action: 'pause' }) + } + if (prompt.text === 'pause') return textReply('GOAL PAUSED') + return textReply('UNEXPECTED PROMPT') + } +} + +export const name = 'goal-tool-scripted-llm' +export const inject = ['llm'] + +export function apply(ctx: Context): void { + ctx.llm.registerAdapter(['goal-script'], new GoalScriptAdapter()) +} diff --git a/examples/package.json b/examples/package.json index 3a3815582a..b1c8ac52cd 100644 --- a/examples/package.json +++ b/examples/package.json @@ -36,6 +36,7 @@ "@deepseek-ai/dsh-tool-cordis": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-fs-search": "workspace:*", + "@deepseek-ai/dsh-tool-goal": "workspace:*", "@deepseek-ai/dsh-tool-subagent": "workspace:*", "@deepseek-ai/dsh-tool-todo": "workspace:*", "@deepseek-ai/dsh-tool-workflow": "workspace:*", diff --git a/knip.json b/knip.json index 6f04363494..eb4eff3a9f 100644 --- a/knip.json +++ b/knip.json @@ -11,6 +11,7 @@ "entry": [ "echo-agent/src/*.ts", "echo-agent/tests/fixtures/goal/goal/seed-goal.ts", + "echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts", "headless-agent/tests/fixtures/cli-mock-llm.ts", "tui-agent/tests/fixtures/tui-scripted-llm.ts", "*/tests/**/*.e2e.ts", @@ -71,6 +72,10 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/goal/tool-goal": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, "packages/code-runtime/code-runtime-worker": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 3097f1abeb..c6b7f9a804 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('boots every shipped tool package and harvests its model-facing schemas', async () => { const catalog = await collectToolCatalog() const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort() - expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'glob', 'grep', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'web_fetch', 'web_search', 'workflow', 'write']) + expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'get_goal', 'glob', 'grep', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) // Every tool carries a JSON-Schema `parameters` object (what the model sees). for (const entry of catalog) { for (const schema of entry.schemas) { diff --git a/packages/goal/README.md b/packages/goal/README.md index 45f1b4f135..1ab912b3c7 100644 --- a/packages/goal/README.md +++ b/packages/goal/README.md @@ -5,5 +5,6 @@ The goal family owns durable objective state independently of the model-facing t | Package | Role | ctx key | |---|---|---| | `goal/` | Event-sourced goal lifecycle, replay fold, compare-and-set mutations, and process-local activation | `ctx.goals` | +| `tool-goal/` | Model-facing read/create/update tools with execution-time authority checks | — | Goal state is part of the owning session log. Consumers depend on `dsh-goal`, not on the concrete agent loop; continuation behavior belongs in a separate plugin on the public agent seams. diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md new file mode 100644 index 0000000000..86def6b65e --- /dev/null +++ b/packages/goal/tool-goal/README.md @@ -0,0 +1,55 @@ +# @deepseek-ai/dsh-tool-goal + +The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal`, `create_goal`, and `update_goal`. The [goal-tool RFC](../../../docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md) owns the authority split and Codex-shaped UX. + +## Tools + +- `get_goal()` returns the current goal or `null`, including the compare-and-set id/revision, durable phase, admitted/capped goal rounds, and current process-local activation. +- `create_goal(objective, max_goal_rounds?)` creates one goal from a direct top-level human turn. The model may infer long-running goal intent without an exact command phrase; non-human turns and subagents are rejected at execution. +- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. Replacements belong only to `edit`. + +All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations. + +## Authority + +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. + +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. Direct human authority may stop a goal immediately. + +## Config + +```yaml +- id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + config: + blockedAfterConsecutiveRounds: 3 +``` + +The value must be a positive safe integer. It supplies both the hard lower bound on model self-blocking and the number named in model guidance. + +## Model Experience + +### System prompt + +**What the model sees**: A fixed goal policy says when semantic human intent warrants creation, requires exact read-before-update refs, explains rearming after resume/fork, and limits completion/blocking claims. The configured threshold is interpolated into that guidance. + +**Token effect**: Small fixed input cost on every request where this plugin's prompt registration is in scope. + +#### Goal policy + +```markdown +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked. +``` + +### Tool schemas and results + +**What the model sees**: The generated [`get_goal`, `create_goal`, and `update_goal` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal). Successful results are compact JSON. Mutation results are followed by the goal domain's raw `` snapshot after the tool batch. `activation` in a result is a live observation and never becomes replay authority. + +**Token effect**: Fixed schema cost plus one compact result per call. Mutations also retain the domain snapshot until compaction. + +## Known Limitations and Deferred Work + +- **Semantic intent remains model judgment** — execution can prove direct human provenance, not whether a request is substantial enough to merit a goal. +- **Same-condition blocking remains model judgment** — the runtime enforces distinct admitted-round count, not semantic equivalence of obstacles; an independent evaluator is deferred. +- **No scheduling or UI commands** — these tools mutate state only; the same-session driver and human command surfaces are separate stack layers. +- **Prompt registration is independent of filtering** — a scope may hide the tools while retaining their guidance unless the deployment scopes both registrations together. diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json new file mode 100644 index 0000000000..28cd6839f8 --- /dev/null +++ b/packages/goal/tool-goal/package.json @@ -0,0 +1,47 @@ +{ + "name": "@deepseek-ai/dsh-tool-goal", + "description": "Model-facing same-session goal tools with execution-time authority checks", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-goal": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/goal/tool-goal/src/authority.ts b/packages/goal/tool-goal/src/authority.ts new file mode 100644 index 0000000000..a2238aab9c --- /dev/null +++ b/packages/goal/tool-goal/src/authority.ts @@ -0,0 +1,105 @@ +/** Execution-time authority checks for the model-facing goal tools. */ + +import type { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { GoalView } from '@deepseek-ai/dsh-goal' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { ToolRunContext } from '@deepseek-ai/dsh-tools' + +type TurnStartEvent = Extract + +/** Current open turn plus the events accepted after its start boundary. */ +export interface GoalToolExecution { + readonly agent: Agent + readonly start: TurnStartEvent + readonly events: readonly SessionEvent[] +} + +/** Hard authority granted to one state-changing call. */ +export type GoalToolAuthority = + | { readonly kind: 'direct-human' } + | { readonly kind: 'goal-round'; readonly goal: GoalView } + +/** Throw one structured tool-policy failure. */ +function reject(message: string, code = 'GOAL_TOOL_AUTHORITY_REQUIRED'): never { + throw new HarnessError(message, code) +} + +/** Locate the open turn enclosing a model tool call. */ +function openTurn(agent: Agent): { start: TurnStartEvent; events: readonly SessionEvent[] } { + const events = agent.session.events + for (let index = events.length - 1; index >= 0; index -= 1) { + const boundary = events[index] + if (boundary?.type === 'turn/end') { + reject('goal tools require an open model turn', 'GOAL_TOOL_DRIVER_REQUIRED') + } + if (boundary?.type === 'turn/start') { + return { start: boundary, events: events.slice(index + 1) } + } + } + return reject('goal tools require an open model turn', 'GOAL_TOOL_DRIVER_REQUIRED') +} + +/** + * Resolve and authenticate the calling agent and its driver boundary. + * @param ctx - Context carrying the live agent registry. + * @param exec - Tool execution metadata supplied by the registry. + * @returns The authenticated agent and its current turn window. + */ +export function goalToolExecution(ctx: Context, exec: ToolRunContext): GoalToolExecution { + const agent = exec.agent + if (agent === undefined) { + return reject('goal tools require a calling agent', 'GOAL_TOOL_AGENT_REQUIRED') + } + if (ctx.agents.get(agent.id) !== agent || agent.status !== 'running' + || ctx.agents.currentInitiator() !== agent) { + return reject( + 'goal tools require the exact live calling agent inside its active driver', + 'GOAL_TOOL_DRIVER_REQUIRED', + ) + } + return { agent, ...openTurn(agent) } +} + +/** Whether an accepted human message appears in the current root-agent turn. */ +function hasDirectHumanInput(ctx: Context, execution: GoalToolExecution): boolean { + if (!ctx.agents.roots().includes(execution.agent)) return false + return execution.events.some(event => + (event.type === 'user/message' || event.type === 'steering/message') + && event.data.source.kind === 'user') +} + +/** Whether this turn is the current goal's exact admitted round. */ +function isMatchingGoalRound(execution: GoalToolExecution, goal: GoalView): boolean { + return execution.events.some(event => event.type === 'user/message' + && event.data.source.kind === 'goal' + && event.data.source.goalId === goal.id + && event.data.source.revision === goal.revision + && event.data.source.round === goal.roundsStarted) +} + +/** + * Require authority originating in a human message accepted by a runtime root. + * @param ctx - Context carrying the live agent graph. + * @param execution - Authenticated current tool execution. + */ +export function requireDirectHuman(ctx: Context, execution: GoalToolExecution): void { + if (hasDirectHumanInput(ctx, execution)) return + reject('this goal operation requires a direct human turn on a top-level agent') +} + +/** + * Resolve completion authority from either direct human input or the exact goal round. + * @param ctx - Context carrying live agents and goal state. + * @param execution - Authenticated current tool execution. + * @returns The direct-human or exact-goal-round authority grant. + */ +export function completionAuthority(ctx: Context, execution: GoalToolExecution): GoalToolAuthority { + if (hasDirectHumanInput(ctx, execution)) return { kind: 'direct-human' } + const goal = ctx.goals.get(execution.agent) + if (goal !== undefined && isMatchingGoalRound(execution, goal)) { + return { kind: 'goal-round', goal } + } + return reject('complete and blocked require a direct human turn or the current goal round') +} diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts new file mode 100644 index 0000000000..9bfa51095f --- /dev/null +++ b/packages/goal/tool-goal/src/index.ts @@ -0,0 +1,222 @@ +/** + * Model-facing `get_goal`, `create_goal`, and `update_goal` tools over the + * persisted same-session goal domain. + * @module @deepseek-ai/dsh-tool-goal + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import { GoalId } from '@deepseek-ai/dsh-goal' +import type { GoalRef, GoalView } from '@deepseek-ai/dsh-goal' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { GenericCallView } from '@deepseek-ai/dsh-tools' +import type {} from '@deepseek-ai/dsh-system-prompt' +import { + completionAuthority, + goalToolExecution, + requireDirectHuman, +} from './authority.ts' + +export const name = 'tool-goal' +export const inject = ['agents', 'goals', 'tools', 'systemPrompt'] + +/** Model policy and hard lower bounds for goal-state updates. */ +export interface Config { + /** Minimum admitted goal rounds before the model may self-report `blocked`. */ + blockedAfterConsecutiveRounds?: number +} + +/** Schemastery config for the goal-tool policy. */ +export const Config: z = z.object({ + blockedAfterConsecutiveRounds: z.number().step(1).min(1).default(3), +}) + +/** Fully materialized tool policy. */ +interface ResolvedConfig { + readonly blockedAfterConsecutiveRounds: number +} + +type UpdateAction = 'edit' | 'pause' | 'resume' | 'complete' | 'blocked' + +const UPDATE_ACTIONS: UpdateAction[] = ['edit', 'pause', 'resume', 'complete', 'blocked'] + +const CREATE_DESCRIPTION = + 'Create one persisted same-session completion goal when the current direct human request ' + + 'is a long-running objective that should continue across autonomous goal rounds. You may ' + + 'infer that intent without requiring the user to say "create a goal". Do not use this for ' + + 'trivial single-turn work. Execution rejects non-human and subagent authority.' + +const GET_DESCRIPTION = + 'Read the current same-session goal, including its exact id/revision, durable phase, admitted ' + + 'round count, cap, and live process-local activation. Call this before updating a goal.' + +/** Render policy guidance with its deployment-selected blocked threshold. */ +function guidance(blockedAfter: number): string { + return 'Use goal tools for one long-running completion objective in the current session. ' + + 'create_goal may infer goal intent from a direct human request in any language; do not ' + + 'create a goal for routine single-turn work. Call get_goal before update_goal and copy its ' + + 'exact goal_id and revision. After session resume or fork, an active goal is disarmed: when ' + + 'a human asks to continue or resume in any wording or language, use update_goal action ' + + 'resume to rearm it. Mark complete only when the objective is actually achieved. Mark ' + + `blocked only after the same blocking condition persists for at least ${blockedAfter} ` + + 'consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked.' +} + +/** Validate config even when apply is called directly outside Loader normalization. */ +function resolveConfig(config: Config): ResolvedConfig { + const blockedAfter = config.blockedAfterConsecutiveRounds ?? 3 + if (!Number.isSafeInteger(blockedAfter) || blockedAfter < 1) { + throw new TypeError('blockedAfterConsecutiveRounds must be a positive safe integer') + } + return { blockedAfterConsecutiveRounds: blockedAfter } +} + +/** Build the exact compare-and-set ref from model arguments. */ +function goalRef(goalId: string, revision: number): GoalRef { + if (goalId.length === 0 || goalId !== goalId.trim() + || !Number.isSafeInteger(revision) || revision < 1) { + throw new HarnessError( + 'goal_id must be non-empty and revision must be a positive safe integer', + 'GOAL_TOOL_INVALID_UPDATE', + ) + } + return { id: GoalId(goalId), revision } +} + +/** Stable compact model result; activation is an observation, not replay state. */ +function renderGoal(goal: GoalView | undefined): string { + if (goal === undefined) return JSON.stringify({ goal: null }) + return JSON.stringify({ + goal: { + id: goal.id, + revision: goal.revision, + objective: goal.objective, + phase: goal.phase, + roundsStarted: goal.roundsStarted, + maxGoalRounds: goal.maxGoalRounds, + }, + activation: goal.activation, + }) +} + +/** Generic, args-only pending presentation shared by the goal tools. */ +function present(title: string, kind: 'read' | 'other', rawInput?: unknown): GenericCallView { + return { card: 'generic', title, kind, ...rawInput === undefined ? {} : { rawInput } } +} + +/** Register the three Codex-shaped goal tools and their shared policy section. */ +export function apply(ctx: Context, config: Config): void { + const resolved = resolveConfig(config) + ctx.systemPrompt.section({ + name: 'tool:goal', + order: 114, + text: guidance(resolved.blockedAfterConsecutiveRounds), + }) + + ctx.tools.register(defineTool({ + name: 'get_goal', + description: GET_DESCRIPTION, + parameters: {}, + execute(_args, exec) { + const execution = goalToolExecution(ctx, exec) + return Promise.resolve([{ + type: 'text', + text: renderGoal(ctx.goals.get(execution.agent)), + }]) + }, + presentCall: () => present('Read current goal', 'read'), + })) + + ctx.tools.register(defineTool({ + name: 'create_goal', + description: CREATE_DESCRIPTION, + parameters: { + objective: { + type: 'string', + required: true, + description: 'The concrete completion objective inferred from the direct human request.', + }, + max_goal_rounds: { + type: 'number', + description: 'Optional positive safe-integer cap; omission uses the goal-domain deployment default.', + }, + }, + execute(args, exec) { + const execution = goalToolExecution(ctx, exec) + requireDirectHuman(ctx, execution) + const goal = ctx.goals.create(execution.agent, { + objective: args.objective, + ...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds }, + }) + return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) + }, + presentCall: args => present('Create goal', 'other', args.objective), + })) + + ctx.tools.register(defineTool({ + name: 'update_goal', + description: 'Update the exact current goal revision. edit, pause, and resume require a direct ' + + 'top-level human turn. complete and blocked additionally accept the exact admitted goal ' + + 'round. blocked is rejected before the configured minimum round count; the model remains ' + + 'responsible for judging that the same condition persisted across those rounds.', + parameters: { + goal_id: { type: 'string', required: true, description: 'Exact id returned by get_goal.' }, + revision: { type: 'number', required: true, description: 'Exact positive revision returned by get_goal.' }, + action: { + type: 'string', + required: true, + enum: UPDATE_ACTIONS, + description: 'edit | pause | resume | complete | blocked', + }, + objective: { type: 'string', description: 'Replacement objective; valid only with action edit.' }, + max_goal_rounds: { type: 'number', description: 'Replacement cap; valid only with action edit.' }, + }, + execute(args, exec) { + const execution = goalToolExecution(ctx, exec) + const ref = goalRef(args.goal_id, args.revision) + const replacements = { + ...args.objective === undefined ? {} : { objective: args.objective }, + ...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds }, + } + if (args.action === 'edit') { + requireDirectHuman(ctx, execution) + return Promise.resolve([{ + type: 'text', + text: renderGoal(ctx.goals.edit(execution.agent, ref, replacements)), + }]) + } + if (args.objective !== undefined || args.max_goal_rounds !== undefined) { + throw new HarnessError( + 'objective and max_goal_rounds are valid only with action edit', + 'GOAL_TOOL_INVALID_UPDATE', + ) + } + if (args.action === 'pause' || args.action === 'resume') { + requireDirectHuman(ctx, execution) + const goal = args.action === 'pause' + ? ctx.goals.pause(execution.agent, ref) + : ctx.goals.resume(execution.agent, ref) + return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) + } + const authority = completionAuthority(ctx, execution) + if (args.action === 'blocked' && authority.kind === 'goal-round' + && authority.goal.roundsStarted < resolved.blockedAfterConsecutiveRounds) { + throw new HarnessError( + `blocked requires at least ${resolved.blockedAfterConsecutiveRounds} consecutive goal rounds; ` + + `current round is ${authority.goal.roundsStarted}`, + 'GOAL_TOOL_BLOCK_THRESHOLD', + ) + } + const goal = args.action === 'complete' + ? ctx.goals.complete(execution.agent, ref) + : ctx.goals.block(execution.agent, ref) + return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) + }, + presentCall: args => present( + `${args.action === 'blocked' ? 'Mark' : args.action.charAt(0).toUpperCase() + args.action.slice(1)} goal`, + 'other', + args.objective ?? args.goal_id, + ), + })) +} diff --git a/packages/goal/tool-goal/tests/tool-goal.e2e.ts b/packages/goal/tool-goal/tests/tool-goal.e2e.ts new file mode 100644 index 0000000000..037b91d18d --- /dev/null +++ b/packages/goal/tool-goal/tests/tool-goal.e2e.ts @@ -0,0 +1,124 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { decodeGoalChange } from '@deepseek-ai/dsh-goal' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' + +const binScript = fileURLToPath(new URL('../../../examples/stdio-demo/src/bin.ts', import.meta.url)) +const configPath = fileURLToPath(new URL( + '../../../../examples/echo-agent/tests/fixtures/goal/tool-goal/cordis.yml', + import.meta.url, +)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) +const PROCESS_TIMEOUT_MS = 30_000 +const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000 + +let child: ChildProcessWithoutNullStreams | undefined +let workdir: string | undefined + +afterEach(async () => { + if (child !== undefined && child.exitCode === null) child.kill('SIGKILL') + child = undefined + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + workdir = undefined +}) + +async function jsonlFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const paths = await Promise.all(entries.map(async (entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) return jsonlFiles(path) + return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : [] + })) + return paths.flat() +} + +async function runComposition(): Promise<{ stdout: string; stderr: string }> { + workdir = await mkdtemp(join(tmpdir(), 'goal-tools-e2e-')) + const cwd = workdir + return new Promise((resolve, reject) => { + const launch = resolveExampleLaunch({ + srcBin: binScript, + configArgs: [configPath], + tsconfigPath: repoTsconfig, + exposeInternals: true, + env: { + DSH_HOME: join(cwd, '.dsh'), + DSH_AGENTS_HOME: join(cwd, '.agents'), + }, + }) + const proc = spawn(launch.command, launch.args, { + cwd, + env: { ...process.env, ...launch.env }, + stdio: ['pipe', 'pipe', 'pipe'], + }) + child = proc + let stdout = '' + let stderr = '' + let pauseSent = false + let inputClosed = false + proc.stdout.setEncoding('utf8') + proc.stdout.on('data', (chunk: string) => { + stdout += chunk + if (!pauseSent && stdout.includes('GOAL CREATED') && stdout.includes('\n> ')) { + pauseSent = true + proc.stdin.write('pause\n') + } + if (!inputClosed && stdout.includes('GOAL PAUSED')) { + inputClosed = true + proc.stdin.end() + } + }) + proc.stderr.setEncoding('utf8') + proc.stderr.on('data', (chunk: string) => { stderr += chunk }) + + const timer = setTimeout(() => { + proc.kill('SIGKILL') + reject(new Error( + `goal-tools e2e did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`, + )) + }, PROCESS_TIMEOUT_MS) + proc.on('exit', (code) => { + clearTimeout(timer) + if (code === 0) resolve({ stdout, stderr }) + else reject(new Error(`goal-tools e2e exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }) + proc.on('error', (error) => { clearTimeout(timer); reject(error) }) + proc.stdin.write('start\n') + }) +} + +describe('goal tools through a real Loader, app, and stdio process', () => { + it('creates, reads, and pauses one root goal with durable tool and state records', async () => { + const { stdout, stderr } = await runComposition() + expect(stderr).not.toContain('UNHANDLED') + expect(stdout).toContain('goal-tools e2e ready.') + expect(stdout).toContain('GOAL CREATED') + expect(stdout).toContain('GOAL PAUSED') + + const logs = await jsonlFiles(join(workdir as string, '.sessions')) + expect(logs).toHaveLength(1) + const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n') + const events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent) + const calls = events.filter(event => event.type === 'tool/call') + expect(calls.map(event => event.data.name)).toEqual(['create_goal', 'get_goal', 'update_goal']) + const results = events.filter(event => event.type === 'tool/result') + expect(results).toHaveLength(3) + expect(results.every(event => !event.data.isError)).toBe(true) + + const changes = events + .filter(event => event.type === 'context/message' && event.data.source.kind === 'goal') + .map(event => event.type === 'context/message' ? decodeGoalChange(event.data.meta) : undefined) + expect(changes.map(change => change?.operation)).toEqual(['create', 'pause']) + expect(changes[1]).toMatchObject({ goal: { phase: 'paused', revision: 2, maxGoalRounds: 7 } }) + expect(JSON.stringify(changes)).not.toContain('activation') + + const headers = events.filter(event => event.type === 'request/header') + expect(JSON.stringify(headers)).toContain('infer goal intent') + expect(JSON.stringify(headers)).toContain('at least 3 consecutive goal rounds') + }, TEST_TIMEOUT_MS) +}) diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts new file mode 100644 index 0000000000..c5f1a00593 --- /dev/null +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -0,0 +1,374 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent' +import GoalService, { GoalId } from '@deepseek-ai/dsh-goal' +import type { GoalRef } from '@deepseek-ai/dsh-goal' +import { CallId } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' +import * as toolGoal from '@deepseek-ai/dsh-tool-goal' + +interface StubAgent { + readonly agent: Agent + readonly session: Session + setStatus(status: AgentStatus): void +} + +/** Build one registry-compatible live agent whose injections append in place. */ +function stubAgent(rawId: string): StubAgent { + const session = new Session(SessionId(rawId)) + let status: AgentStatus = 'running' + const agent: Agent = { + id: session.id, + options: {}, + session, + get status() { return status }, + ctx: new Context(), + send() {}, + steer() {}, + inject(content: ContentBlock[], options?: InjectOptions) { + const source = options?.source ?? { kind: 'user' } + session.append('context/message', { + content, + source, + ...options?.envelope === undefined ? {} : { envelope: options.envelope }, + ...options?.meta === undefined ? {} : { meta: options.meta }, + }, { surfaceOp: 'append' }) + }, + cancel() {}, + whenIdle() { return Promise.resolve() }, + } + return { agent, session, setStatus(value) { status = value } } +} + +/** Open one message-triggered turn with its accepted model-visible input. */ +function openTurn(stub: StubAgent, source: MessageSource, text = 'prompt'): number { + const turn = stub.session.events + .filter(event => event.type === 'turn/start') + .reduce((max, event) => Math.max(max, event.data.turn), 0) + 1 + stub.session.append('turn/start', { turn, trigger: { kind: 'message', source } }) + stub.session.append('user/message', { + content: [{ type: 'text', text }], + source, + }, { surfaceOp: 'append' }) + return turn +} + +/** Close the currently open test turn. */ +function closeTurn(stub: StubAgent, turn: number): void { + stub.session.append('turn/end', { turn, reason: { kind: 'completed' } }) +} + +async function harness(config: toolGoal.Config = {}) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(AgentRegistry) + await ctx.plugin(ToolRegistry) + await ctx.plugin(GoalService) + const fiber = await ctx.plugin(toolGoal, config) + const root = stubAgent(`goal-tool-root-${Math.random()}`) + ctx.agents.register(root.agent) + return { ctx, fiber, root } +} + +/** Execute one registered tool under an optional driver initiator. */ +async function execute( + ctx: Context, + name: string, + args: unknown, + agent?: Agent, + initiator: Agent | undefined = agent, +): Promise { + const run = () => ctx.tools.execute({ + callId: CallId(`call-${Math.random()}`), + name, + arguments: args, + ...agent === undefined ? {} : { agent }, + }) + return initiator === undefined ? run() : ctx.agents.withInitiator(initiator, run) +} + +/** Parse the compact JSON returned by a successful goal tool. */ +function resultJson(result: ToolExecutionResult): Record { + expect(result.isError).toBe(false) + const block = result.content[0] + if (block?.type !== 'text') throw new Error('expected text tool result') + return JSON.parse(block.text) as Record +} + +/** Read the returned goal sub-object. */ +function resultGoal(result: ToolExecutionResult): Record { + const goal = resultJson(result)['goal'] + if (typeof goal !== 'object' || goal === null) throw new Error('expected returned goal') + return goal as Record +} + +describe('goal tool registration and presentation', () => { + it('registers three exclusive tools plus configured guidance and disposes all contributions', async () => { + const { ctx, fiber } = await harness({ blockedAfterConsecutiveRounds: 5 }) + expect(['create_goal', 'get_goal', 'update_goal'].map(name => ctx.tools.get(name)?.name)) + .toEqual(['create_goal', 'get_goal', 'update_goal']) + for (const name of ['create_goal', 'get_goal', 'update_goal']) { + expect(ctx.tools.executionMode({ callId: CallId(name), name, arguments: {} })) + .toEqual({ kind: 'exclusive' }) + } + const section = (await ctx.systemPrompt.assemble()).sections.find(item => item.name === 'tool:goal') + expect(section?.text).toContain('infer goal intent') + expect(section?.text).toContain('at least 5 consecutive goal rounds') + + await fiber.dispose() + expect(ctx.tools.get('get_goal')).toBeUndefined() + expect((await ctx.systemPrompt.assemble()).sections.some(item => item.name === 'tool:goal')).toBe(false) + }) + + it('uses args-only generic render intent and soft-fails malformed replay args', async () => { + const { ctx } = await harness() + expect(ctx.tools.get('get_goal')?.presentCall?.({})).toEqual({ + card: 'generic', title: 'Read current goal', kind: 'read', + }) + expect(ctx.tools.get('create_goal')?.presentCall?.({ objective: 'ship' })).toEqual({ + card: 'generic', title: 'Create goal', kind: 'other', rawInput: 'ship', + }) + expect(ctx.tools.get('update_goal')?.presentCall?.({ + goal_id: 'goal-1', revision: 2, action: 'blocked', + })).toEqual({ card: 'generic', title: 'Mark goal', kind: 'other', rawInput: 'goal-1' }) + expect(ctx.tools.get('update_goal')?.presentCall?.({ + goal_id: 'goal-1', revision: 2, action: 'resume', + })).toEqual({ card: 'generic', title: 'Resume goal', kind: 'other', rawInput: 'goal-1' }) + expect(ctx.tools.get('update_goal')?.presentCall?.({ wrong: true })).toBeUndefined() + }) + + it('has the Loader-safe namespace export shape', () => { + expect('default' in toolGoal).toBe(false) + expect(toolGoal.name).toBe('tool-goal') + expect(toolGoal.inject).toEqual(['agents', 'goals', 'tools', 'systemPrompt']) + const loader = Object.create(Loader.prototype) as Loader + expect(loader.unwrapExports(toolGoal)).toBe(toolGoal) + }) + + it('fails invalid direct config before registering anything', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(AgentRegistry) + await ctx.plugin(ToolRegistry) + await ctx.plugin(GoalService) + expect(() => { + toolGoal.apply(ctx, { blockedAfterConsecutiveRounds: 1.5 }) + }).toThrow( + 'blockedAfterConsecutiveRounds must be a positive safe integer', + ) + expect(ctx.tools.get('get_goal')).toBeUndefined() + }) + + it('resolves the direct-apply default before registration', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(AgentRegistry) + await ctx.plugin(ToolRegistry) + await ctx.plugin(GoalService) + toolGoal.apply(ctx, {}) + const section = (await ctx.systemPrompt.assemble()).sections.find(item => item.name === 'tool:goal') + expect(section?.text).toContain('at least 3 consecutive goal rounds') + }) +}) + +describe('goal tool execution authority', () => { + it('lets a root model infer create intent from its accepted human turn', async () => { + const { ctx, root } = await harness() + openTurn(root, { kind: 'user' }, '请持续工作直到这个功能完成') + const result = await execute(ctx, 'create_goal', { + objective: 'Finish the feature', max_goal_rounds: 9, + }, root.agent) + expect(resultGoal(result)).toMatchObject({ + objective: 'Finish the feature', revision: 1, phase: 'active', maxGoalRounds: 9, + }) + expect(resultJson(result)['activation']).toBe('armed') + expect(ctx.goals.get(root.agent)?.objective).toBe('Finish the feature') + }) + + it('rejects agentless, driverless, non-human, and live-child creation', async () => { + const { ctx, root } = await harness() + const agentless = await execute(ctx, 'get_goal', {}) + expect(agentless.error?.code).toBe('GOAL_TOOL_AGENT_REQUIRED') + + openTurn(root, { kind: 'user' }) + const driverless = await ctx.tools.execute({ + callId: CallId('call-driverless'), + name: 'get_goal', + arguments: {}, + agent: root.agent, + }) + expect(driverless.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + closeTurn(root, 1) + + openTurn(root, { kind: 'plugin', plugin: 'test' }) + const nonHuman = await execute(ctx, 'create_goal', { objective: 'forged' }, root.agent) + expect(nonHuman.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') + closeTurn(root, 2) + + const child = stubAgent('goal-tool-child') + ctx.agents.enter(child.agent, root.agent) + ctx.agents.announce(child.agent) + openTurn(child, { kind: 'user' }) + const childResult = await execute(ctx, 'create_goal', { objective: 'child goal' }, child.agent) + expect(childResult.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') + }) + + it('rejects calls before a turn and after its end boundary', async () => { + const { ctx, root } = await harness() + const before = await execute(ctx, 'get_goal', {}, root.agent) + expect(before.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + + const turn = openTurn(root, { kind: 'user' }) + closeTurn(root, turn) + const after = await execute(ctx, 'get_goal', {}, root.agent) + expect(after.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + }) + + it('rejects terminal reporting without human input or a current goal round', async () => { + const { ctx, root } = await harness() + openTurn(root, { kind: 'plugin', plugin: 'test' }) + const result = await execute(ctx, 'update_goal', { + goal_id: 'goal-missing', revision: 1, action: 'complete', + }, root.agent) + expect(result.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') + }) + + it('accepts direct human steering in a goal-sourced root turn', async () => { + const { ctx, root } = await harness() + const humanTurn = openTurn(root, { kind: 'user' }) + const created = ctx.goals.create(root.agent, { objective: 'steer me' }) + closeTurn(root, humanTurn) + const round = openTurn(root, { + kind: 'goal', goalId: created.id, revision: created.revision, round: 1, + }) + root.session.append('steering/message', { + turn: round, + content: [{ type: 'text', text: 'pause now' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + const paused = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'pause', + }, root.agent) + expect(resultGoal(paused)).toMatchObject({ phase: 'paused', revision: 2 }) + }) + + it('rejects an initiator different from exec.agent', async () => { + const { ctx, root } = await harness() + const other = stubAgent('goal-tool-other') + ctx.agents.register(other.agent) + openTurn(other, { kind: 'user' }) + const result = await execute(ctx, 'get_goal', {}, other.agent, root.agent) + expect(result.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + }) +}) + +describe('goal tool state transitions', () => { + it('reads null, then edits, pauses, and resumes by exact revision in one human turn', async () => { + const { ctx, root } = await harness() + openTurn(root, { kind: 'user' }) + expect(resultJson(await execute(ctx, 'get_goal', {}, root.agent))).toEqual({ goal: null }) + let goal = resultGoal(await execute(ctx, 'create_goal', { objective: 'old' }, root.agent)) + goal = resultGoal(await execute(ctx, 'update_goal', { + goal_id: goal['id'], revision: goal['revision'], action: 'edit', + objective: 'new', max_goal_rounds: 8, + }, root.agent)) + expect(goal).toMatchObject({ objective: 'new', revision: 2, maxGoalRounds: 8 }) + goal = resultGoal(await execute(ctx, 'update_goal', { + goal_id: goal['id'], revision: goal['revision'], action: 'pause', + }, root.agent)) + expect(goal).toMatchObject({ phase: 'paused', revision: 3 }) + goal = resultGoal(await execute(ctx, 'update_goal', { + goal_id: goal['id'], revision: goal['revision'], action: 'resume', + }, root.agent)) + expect(goal).toMatchObject({ phase: 'active', revision: 4 }) + }) + + it('rearms a restored active goal only after a new direct human prompt', async () => { + const { ctx, root } = await harness() + let turn = openTurn(root, { kind: 'user' }) + const created = ctx.goals.create(root.agent, { objective: 'continue later' }) + closeTurn(root, turn) + agentEvents(ctx, root.agent).emit('agent/session-start', 'resume') + expect(ctx.goals.get(root.agent)?.activation).toBe('disarmed') + turn = openTurn(root, { kind: 'user' }, '继续') + const resumed = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'resume', + }, root.agent) + expect(resultGoal(resumed)).toMatchObject({ phase: 'active', revision: 2 }) + expect(resultJson(resumed)['activation']).toBe('armed') + closeTurn(root, turn) + }) + + it('returns structured domain and conditional-argument failures', async () => { + const { ctx, root } = await harness() + openTurn(root, { kind: 'user' }) + const invalidCreate = await execute(ctx, 'create_goal', { objective: ' ' }, root.agent) + expect(invalidCreate.error?.code).toBe('GOAL_INVALID_OBJECTIVE') + const created = ctx.goals.create(root.agent, { objective: 'valid' }) + const replacement = await execute(ctx, 'update_goal', { + goal_id: created.id, + revision: created.revision, + action: 'pause', + objective: 'not valid for pause', + }, root.agent) + expect(replacement.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + const malformedRef = await execute(ctx, 'update_goal', { + goal_id: '', revision: 0, action: 'edit', objective: 'x', + }, root.agent) + expect(malformedRef.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + }) + + it('allows exact goal rounds to complete but not edit or pause', async () => { + const { ctx, root } = await harness() + const humanTurn = openTurn(root, { kind: 'user' }) + const created = ctx.goals.create(root.agent, { objective: 'round-owned' }) + closeTurn(root, humanTurn) + openTurn(root, { kind: 'goal', goalId: created.id, revision: created.revision, round: 1 }) + const edit = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'edit', objective: 'forbidden', + }, root.agent) + expect(edit.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') + const complete = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'complete', + }, root.agent) + expect(resultGoal(complete)).toMatchObject({ phase: 'complete', revision: 2, roundsStarted: 1 }) + }) + + it('enforces the configured model self-block lower bound across admitted rounds', async () => { + const { ctx, root } = await harness({ blockedAfterConsecutiveRounds: 3 }) + let turn = openTurn(root, { kind: 'user' }) + const created = ctx.goals.create(root.agent, { objective: 'blocked eventually' }) + closeTurn(root, turn) + const ref: GoalRef = { id: GoalId(created.id), revision: created.revision } + + for (let round = 1; round <= 2; round += 1) { + turn = openTurn(root, { kind: 'goal', goalId: ref.id, revision: ref.revision, round }) + const result = await execute(ctx, 'update_goal', { + goal_id: ref.id, revision: ref.revision, action: 'blocked', + }, root.agent) + expect(result.error?.code).toBe('GOAL_TOOL_BLOCK_THRESHOLD') + closeTurn(root, turn) + } + openTurn(root, { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 3 }) + const blocked = await execute(ctx, 'update_goal', { + goal_id: ref.id, revision: ref.revision, action: 'blocked', + }, root.agent) + expect(resultGoal(blocked)).toMatchObject({ phase: 'blocked', roundsStarted: 3 }) + }) + + it('lets direct human authority block before the model threshold', async () => { + const { ctx, root } = await harness({ blockedAfterConsecutiveRounds: 9 }) + openTurn(root, { kind: 'user' }) + const created = ctx.goals.create(root.agent, { objective: 'human stop' }) + const blocked = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'blocked', + }, root.agent) + expect(resultGoal(blocked)).toMatchObject({ phase: 'blocked', roundsStarted: 0 }) + }) +}) diff --git a/packages/goal/tool-goal/tsconfig.json b/packages/goal/tool-goal/tsconfig.json new file mode 100644 index 0000000000..d5c34d4e29 --- /dev/null +++ b/packages/goal/tool-goal/tsconfig.json @@ -0,0 +1,39 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../goal" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f1fa58dce..e1d3bd1fe7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -185,6 +185,9 @@ importers: '@deepseek-ai/dsh-tool-fs-search': specifier: workspace:* version: link:../packages/fs/tool-fs-search + '@deepseek-ai/dsh-tool-goal': + specifier: workspace:* + version: link:../packages/goal/tool-goal '@deepseek-ai/dsh-tool-subagent': specifier: workspace:* version: link:../packages/subagent/tool-subagent @@ -1007,6 +1010,40 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/goal/tool-goal: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../goal + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + packages/guard/repeat-tool-guard: dependencies: schemastery: diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 8606d30895..1921dbf27c 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -10,6 +10,8 @@ import { globSync, readFileSync, writeFileSync } from 'node:fs' import { basename, resolve } from 'node:path' import { Context } from 'cordis' import type { ToolSchema } from '@deepseek-ai/dsh-llm' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import GoalService from '@deepseek-ai/dsh-goal' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' @@ -28,6 +30,7 @@ import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search' +import * as ToolGoal from '@deepseek-ai/dsh-tool-goal' import * as ToolSkill from '@deepseek-ai/dsh-tool-skill' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' @@ -178,6 +181,20 @@ const TOOL_PACKAGES: ToolPackage[] = [ note: 'glob and grep are bash-backed discovery tools: they run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.', }, + { + pkg: '@deepseek-ai/dsh-tool-goal', + dir: 'tool-goal', + source: 'packages/goal/tool-goal/src/index.ts', + requires: ['ctx.tools', 'ctx.agents', 'ctx.goals', 'ctx.systemPrompt', 'a calling Agent in an authorized open turn'], + writes: ['tool/call', 'context/message goal snapshot for mutations', 'tool/result'], + async mount(ctx) { + await ctx.plugin(AgentRegistry) + await ctx.plugin(GoalService) + await ctx.plugin(ToolGoal) + }, + note: + 'create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds.', + }, { pkg: '@deepseek-ai/dsh-tool-skill', dir: 'tool-skill', diff --git a/tsconfig.build.json b/tsconfig.build.json index 668541ade3..db84df1542 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -26,6 +26,7 @@ { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, { "path": "./packages/goal/goal" }, + { "path": "./packages/goal/tool-goal" }, { "path": "./packages/context/time-context" }, { "path": "./packages/ui/user-interaction" }, { "path": "./packages/ui/user-approval" }, diff --git a/tsconfig.json b/tsconfig.json index 156792259e..27b0e1d184 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -37,6 +37,7 @@ { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, { "path": "./packages/goal/goal" }, + { "path": "./packages/goal/tool-goal" }, { "path": "./packages/context/time-context" }, { "path": "./packages/ui/user-interaction" }, { "path": "./packages/ui/user-approval" }, From fbefc1d65b3cc20936bb4fd0a079e5a41a8315d7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:42:17 +0800 Subject: [PATCH 02/25] fix(goal): stop terminal goal tool turns --- docs/config-catalog.md | 2 +- docs/event-producer-consumer.md | 2 +- ...26-07-19-model-facing-goal-tools.i18n.yaml | 4 +-- .../2026-07-19-model-facing-goal-tools.md | 2 ++ .../2026-07-19-model-facing-goal-tools.zh.md | 2 ++ packages/goal/tool-goal/README.md | 2 ++ packages/goal/tool-goal/src/index.ts | 28 ++++++++++++++++++- .../goal/tool-goal/tests/tool-goal.spec.ts | 13 +++++++++ 8 files changed, 50 insertions(+), 5 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8f95bad05b..64cbb27222 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1110,7 +1110,7 @@ export interface Config { } ``` -Source: [`packages/goal/tool-goal/src/index.ts:25`](../packages/goal/tool-goal/src/index.ts) +Source: [`packages/goal/tool-goal/src/index.ts:27`](../packages/goal/tool-goal/src/index.ts) ## `@deepseek-ai/dsh-tool-skill` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 8f1fd333fe..d48349cdf0 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -22,7 +22,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:165`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:252`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:288`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml index 0d66c0c360..e4e0fbf855 100644 --- a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml @@ -2,5 +2,5 @@ # 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: # pnpm run verify-translation-pairing --write -2026-07-19-model-facing-goal-tools.md: 7a207bbc73e13ca5111d73ee2c58cf05fbe6387e -2026-07-19-model-facing-goal-tools.zh.md: 46ad7671e1ef2a6856df6d1ec893896aab9760b5 +2026-07-19-model-facing-goal-tools.md: cb823aa944d69228005884ac73cc99b67fa00dfb +2026-07-19-model-facing-goal-tools.zh.md: f93ffa3a2eed602d8c7d98faccf09c9e46924f28 diff --git a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md index 7a207bbc73..cb823aa944 100644 --- a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md +++ b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md @@ -22,6 +22,8 @@ The prompt tells the model that it may infer goal intent from a direct human req All three tools use exclusive execution so a model-ordered batch observes prior mutations and their new revisions. Results are compact JSON. ACP presentation is a pure function of arguments and uses generic read or mutation cards; activation is reported only as live observation and is never written into replay state. +A successful update that leaves the goal stopped contributes the existing terminal `agent/turn-stop` decision for that physical turn, preventing an unnecessary follow-up request after pause, block, or completion. A later successful resume in the same turn removes that contribution. + ### Execution authority Every call requires an `exec.agent` that is the exact running object in `AgentRegistry`, is the current inherited driver initiator, and has an open turn. These are execution-time checks and cannot be bypassed by prompt injection or hand-authored tool arguments. diff --git a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md index 46ad7671e1..f93ffa3a2e 100644 --- a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md +++ b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md @@ -22,6 +22,8 @@ Status: implemented 三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。ACP 展示是参数的纯函数,使用通用读取或变更卡片;激活态仅作为实时观察返回,绝不会写入回放状态。 +成功更新后若目标处于停止状态,插件会为该物理轮次贡献现有的终止型 `agent/turn-stop` 决策,避免在暂停、阻塞或完成后再发起一次不必要的模型请求。同一轮次中后续成功的恢复会移除该贡献。 + ### 执行权限 每次调用都要求存在 `exec.agent`,且它必须是 `AgentRegistry` 中完全相同的运行中对象、当前继承的驱动发起者,并处于开放轮次内。这些检查在执行时进行,不能通过提示词注入或手写工具参数绕过。 diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index 86def6b65e..a3987e32e1 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -10,6 +10,8 @@ The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations. +A successful mutation that leaves the goal stopped contributes the existing terminal `agent/turn-stop` decision for that physical turn. A later same-turn resume clears the contribution. This avoids an extra model request after pause, block, or completion without changing ordinary loop continuation. + ## Authority 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. diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 9bfa51095f..29068a4afa 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -6,6 +6,7 @@ import type { Context } from 'cordis' import z from 'schemastery' +import type { Agent } from '@deepseek-ai/dsh-agent' import { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef, GoalView } from '@deepseek-ai/dsh-goal' import { HarnessError } from '@deepseek-ai/dsh-llm' @@ -17,6 +18,7 @@ import { goalToolExecution, requireDirectHuman, } from './authority.ts' +import type { GoalToolExecution } from './authority.ts' export const name = 'tool-goal' export const inject = ['agents', 'goals', 'tools', 'systemPrompt'] @@ -105,9 +107,28 @@ function present(title: string, kind: 'read' | 'other', rawInput?: unknown): Gen return { card: 'generic', title, kind, ...rawInput === undefined ? {} : { rawInput } } } +/** Remember whether one successful mutation makes this turn terminal. */ +function observeMutation( + terminalTurns: WeakMap, + execution: GoalToolExecution, + goal: GoalView, +): void { + if (goal.phase === 'active' && goal.activation === 'armed') { + terminalTurns.delete(execution.agent) + return + } + terminalTurns.set(execution.agent, execution.start.data.turn) +} + /** Register the three Codex-shaped goal tools and their shared policy section. */ export function apply(ctx: Context, config: Config): void { const resolved = resolveConfig(config) + const terminalTurns = new WeakMap() + ctx.on('agent/turn-stop', (agent, turn) => { + if (terminalTurns.get(agent) !== turn) return undefined + terminalTurns.delete(agent) + return { action: 'stop' } + }) ctx.systemPrompt.section({ name: 'tool:goal', order: 114, @@ -149,6 +170,7 @@ export function apply(ctx: Context, config: Config): void { objective: args.objective, ...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds }, }) + observeMutation(terminalTurns, execution, goal) return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) }, presentCall: args => present('Create goal', 'other', args.objective), @@ -181,9 +203,11 @@ export function apply(ctx: Context, config: Config): void { } if (args.action === 'edit') { requireDirectHuman(ctx, execution) + const goal = ctx.goals.edit(execution.agent, ref, replacements) + observeMutation(terminalTurns, execution, goal) return Promise.resolve([{ type: 'text', - text: renderGoal(ctx.goals.edit(execution.agent, ref, replacements)), + text: renderGoal(goal), }]) } if (args.objective !== undefined || args.max_goal_rounds !== undefined) { @@ -197,6 +221,7 @@ export function apply(ctx: Context, config: Config): void { const goal = args.action === 'pause' ? ctx.goals.pause(execution.agent, ref) : ctx.goals.resume(execution.agent, ref) + observeMutation(terminalTurns, execution, goal) return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) } const authority = completionAuthority(ctx, execution) @@ -211,6 +236,7 @@ export function apply(ctx: Context, config: Config): void { const goal = args.action === 'complete' ? ctx.goals.complete(execution.agent, ref) : ctx.goals.block(execution.agent, ref) + observeMutation(terminalTurns, execution, goal) return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) }, presentCall: args => present( diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index c5f1a00593..2532b16a15 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -287,6 +287,19 @@ describe('goal tool state transitions', () => { goal_id: goal['id'], revision: goal['revision'], action: 'resume', }, root.agent)) expect(goal).toMatchObject({ phase: 'active', revision: 4 }) + expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', 1)).toBeUndefined() + }) + + it('stops the current turn after a successful stopped-state mutation', async () => { + const { ctx, root } = await harness() + openTurn(root, { kind: 'user' }) + const created = ctx.goals.create(root.agent, { objective: 'pause cleanly' }) + const paused = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'pause', + }, root.agent) + expect(resultGoal(paused)).toMatchObject({ phase: 'paused' }) + expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', 1)).toEqual({ action: 'stop' }) + expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', 1)).toBeUndefined() }) it('rearms a restored active goal only after a new direct human prompt', async () => { From 25555c9cfc33b0e7eb0d656cb3ae011d6e7bfdab Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 20:30:43 +0800 Subject: [PATCH 03/25] feat(goal): drive same-session goal rounds --- docs/architecture.md | 2 +- docs/config-catalog.md | 1 + docs/cordis-catalog/events.md | 51 +- docs/cordis-catalog/services.md | 9 + docs/core-data-structures/core.md | 7 +- docs/event-producer-consumer.md | 35 +- docs/module-graph.md | 6 + docs/rfc/INDEX.md | 1 + ...rsisted-same-session-goal-domain.i18n.yaml | 4 +- ...7-19-persisted-same-session-goal-domain.md | 4 +- ...9-persisted-same-session-goal-domain.zh.md | 4 +- ...9-same-session-goal-round-driver.i18n.yaml | 6 + ...26-07-19-same-session-goal-round-driver.md | 100 +++ ...07-19-same-session-goal-round-driver.zh.md | 100 +++ .../fixtures/goal/goal-session/cordis.yml | 25 + .../goal/goal-session/scripted-llm.ts | 104 +++ examples/package.json | 1 + knip.json | 5 + .../cordis/tool-cordis/src/api-catalog.ts | 11 + packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/agent.ts | 9 +- packages/core/agent-loop/tests/cancel.spec.ts | 29 +- packages/core/agent/README.md | 4 +- packages/core/agent/src/types.ts | 17 +- packages/goal/README.md | 1 + packages/goal/goal-session/README.md | 62 ++ packages/goal/goal-session/package.json | 43 ++ packages/goal/goal-session/src/index.ts | 443 ++++++++++++ packages/goal/goal-session/src/outcome.ts | 48 ++ packages/goal/goal-session/src/prompt.ts | 26 + .../goal-session/tests/goal-session.e2e.ts | 138 ++++ .../goal-session/tests/goal-session.spec.ts | 648 ++++++++++++++++++ packages/goal/goal-session/tsconfig.json | 30 + packages/goal/goal/README.md | 4 +- packages/goal/goal/src/index.ts | 15 + packages/goal/goal/tests/goal.spec.ts | 14 + .../invariants/src/scoped-events.generated.ts | 1 + pnpm-lock.yaml | 36 + tsconfig.build.json | 1 + tsconfig.json | 1 + website/zh-CN/api/harness/events.md | 56 +- website/zh-CN/api/harness/goals.md | 39 +- 42 files changed, 2065 insertions(+), 78 deletions(-) create mode 100644 docs/rfc/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-07-19-same-session-goal-round-driver.md create mode 100644 docs/rfc/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md create mode 100644 examples/echo-agent/tests/fixtures/goal/goal-session/cordis.yml create mode 100644 examples/echo-agent/tests/fixtures/goal/goal-session/scripted-llm.ts create mode 100644 packages/goal/goal-session/README.md create mode 100644 packages/goal/goal-session/package.json create mode 100644 packages/goal/goal-session/src/index.ts create mode 100644 packages/goal/goal-session/src/outcome.ts create mode 100644 packages/goal/goal-session/src/prompt.ts create mode 100644 packages/goal/goal-session/tests/goal-session.e2e.ts create mode 100644 packages/goal/goal-session/tests/goal-session.spec.ts create mode 100644 packages/goal/goal-session/tsconfig.json diff --git a/docs/architecture.md b/docs/architecture.md index 2c6cd9c9ea..d0cc86895a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -117,7 +117,7 @@ Tool-time context—including async `agent.inject()` notices and post-tool `addi The turn is the containment boundary. Final adapter-path and terminal in-band failures close the step before `agent/request-error`; retry opens a numbered step; otherwise, the provider error survives. Attempts reset on success. -Other failures use `agent/error`. Cancellation and disposal beat recovery; undispatched model tool calls receive synthetic `tool/call` and `ABORTED` result pairs before `turn/end`. `cancel()` clears queues and aborts active work; disposal awaits quiescence before unregistering. +Other failures use `agent/error`. Cancellation beats recovery; undispatched calls get synthetic `ABORTED` results. Effective `cancel()` emits `agent/cancel-requested` before queue clearing or abort; observers cannot veto it, and idle calls emit nothing. Disposal awaits quiescence. Every session event is turn-enclosed. Reloading preserves an interrupted tail and closes it with a synthetic `interrupted` turn end. Failures after durable turn close report only through `agent/error` because no safe in-turn position remains. Each turn has one `TurnEndReason`; [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) owns the variants. diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 64cbb27222..7b3a700742 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1486,6 +1486,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts)) +- `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts)) - `@deepseek-ai/dsh-invariants` — requires `sessions` ([`packages/support/invariants/src/index.ts`](../packages/support/invariants/src/index.ts)) - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 0f5a4650cd..2ab18f89b8 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -13,6 +13,27 @@ Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `n ## `agent/*` +### `agent/cancel-requested` — emit + +Effective broad cancellation was requested, before queued/steering work is cleared or the active step is aborted. This observe-only notification cannot veto cancellation; listener failures are contained. + +```ts cordis-catalog +/** + * Effective broad cancellation was requested, before queued/steering work + * is cleared or the active step is aborted. This observe-only notification + * cannot veto cancellation; listener failures are contained. + * @param agent - the agent whose current work is being cancelled. + * @param reason - resolved cancellation reason, including the default. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ +'agent/cancel-requested'(this: Scoped, agent: Agent, reason: string): void +``` + +Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) + +Source: [`packages/core/agent/src/types.ts:186`](../../packages/core/agent/src/types.ts) + ### `agent/created` — emit A fully configured agent and live session were published. Setup is composition-only; `agent/session-start` is the first startup-driving seam. Synchronous listener failure vetoes publication, while returned-promise rejection is reported. Detach requested during dispatch waits until every creation listener has observed the stable entry. @@ -33,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:147`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:148`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -53,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:156`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:157`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -75,7 +96,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:311`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:322`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial @@ -98,7 +119,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:264`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:275`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -121,7 +142,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:204`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:215`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -142,7 +163,7 @@ Allow, rewrite, or block one drained prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:225`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -163,7 +184,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:175`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:176`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -186,7 +207,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:226`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:237`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -211,7 +232,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens Types: [Agent](../core-data-structures/core.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:278`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:289`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -237,7 +258,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:241`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:252`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -259,7 +280,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:188`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:199`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -279,7 +300,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:165`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:166`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -301,7 +322,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:252`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:263`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -322,7 +343,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:288`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:299`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -343,7 +364,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:298`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:309`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 934086dd97..de04e18628 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -499,6 +499,15 @@ resolveCreate(request: CreateGoalRequest): CreateGoalSpec */ get(agent: Agent): GoalView | undefined +/** + * Remove process-local continuation authority without changing durable goal + * phase or revision. Lifecycle owners use this before unloading a driver; + * a later human-authorized {@link resume} records the new activation edge. + * @param agent - owning live agent. + * @returns a fresh disarmed view, or `undefined` when no goal is current. + */ +disarm(agent: Agent): GoalView | undefined + /** * Create and arm a goal. A completed goal may be replaced; every other * current phase must be cleared or resumed instead. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 25ded90954..76d94cec79 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -386,9 +386,10 @@ interface Agent { /** * Clear queued and steering work, including work waiting to start, and abort - * the active step. The supplied reason is preserved across pre-step and active - * cancellation windows, and `whenIdle()` resolves after cancellation reaches - * quiescence. Idle cancellation is a no-op and does not arm a later cancel. + * the active step. An effective call first emits `agent/cancel-requested` with + * the resolved reason. The supplied reason is preserved across pre-step and + * active cancellation windows, and `whenIdle()` resolves after cancellation + * reaches quiescence. Idle cancellation is a no-op and does not arm a later cancel. */ cancel(reason?: string): void diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d48349cdf0..96bac92e01 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,30 +8,31 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:362`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:147`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:156`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:264`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:204`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:214`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:175`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:226`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:278`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:241`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:188`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:165`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:252`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:288`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:186`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:148`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:157`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:322`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:275`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:215`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:225`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:176`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:237`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:289`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:252`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:199`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:166`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:263`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:299`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:309`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:53`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:166`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | - | +| `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:166`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:43`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`workspace-context`](../packages/context/workspace-context) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:112`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:86`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 23879ed342..71f69f0594 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -30,6 +30,7 @@ flowchart TD end subgraph group_goal["packages/goal"] pkg_goal["goal"] + pkg_goal_session["goal-session"] pkg_tool_goal["tool-goal"] end subgraph group_bash["packages/bash"] @@ -248,6 +249,10 @@ flowchart TD pkg_tools --> pkg_session pkg_tools --> pkg_system_prompt pkg_tools --> pkg_user_approval + pkg_goal_session --> pkg_agent + pkg_goal_session --> pkg_goal + pkg_goal_session --> pkg_llm + pkg_goal_session --> pkg_session pkg_bash_sandbox --> pkg_bash pkg_bash_sandbox --> pkg_bash_local pkg_bash_sandbox --> pkg_sandbox @@ -515,6 +520,7 @@ flowchart TD | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | +| [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index d3c408f214..72e60ffca7 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -91,6 +91,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Dedicated full-screen TUI front door](implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) | 2026-07-17 | | [Model-facing same-session goal tools](implemented/feature/2026-07-19-model-facing-goal-tools.md) | 2026-07-19 | | [Persisted same-session goal domain](implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) | 2026-07-19 | +| [Same-session goal-round driver](implemented/feature/2026-07-19-same-session-goal-round-driver.md) | 2026-07-19 | ### Simplification diff --git a/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml b/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml index 771afcf5bf..4127a21d0f 100644 --- a/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml @@ -2,5 +2,5 @@ # 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: # pnpm run verify-translation-pairing --write -2026-07-19-persisted-same-session-goal-domain.md: ebf6168a4d8552d40e22db309f953f6f273511a9 -2026-07-19-persisted-same-session-goal-domain.zh.md: 5fd35ee8faffc1c53d257bee48f36d70838fdee1 +2026-07-19-persisted-same-session-goal-domain.md: eab031a5ad174c9e2323576bb69919ac8e853958 +2026-07-19-persisted-same-session-goal-domain.zh.md: 606268d80a0d554418dcac3cc50e84cae6e324b1 diff --git a/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md b/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md index ebf6168a4d..eab031a5ad 100644 --- a/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md +++ b/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md @@ -28,7 +28,7 @@ When `Agent.inject()` defers a mutation inside an active tool batch, the service At most one goal is current. Create requires no current non-complete goal and always generates a revision-one id not used earlier in the session; a completed goal may be replaced. Every other mutation carries the expected `GoalRef`, and stale ids or revisions reject. Resume accepts a stopped phase or a disarmed active goal only when the round cap has remaining capacity; budget limiting requires the admitted count to have reached the cap. -A cache built from any seed starts disarmed, and every `agent/session-start` edge disarms it again. Resume and fork therefore preserve the durable objective and history but never initiate work on their own. A later human prompt can be interpreted by the model, whose policy surface may explicitly call resume and arm the goal. +A cache built from any seed starts disarmed, and every `agent/session-start` edge disarms it again. `GoalService.disarm(agent)` also lets a lifecycle owner remove process-local authority without a session event, revision change, or `goal/changed` notification. Resume, fork, and continuation-driver replacement therefore preserve the durable objective and history but never initiate work on their own. A later human prompt can be interpreted by the model, whose policy surface may explicitly call resume and arm the goal. ### Service boundary @@ -36,7 +36,7 @@ The service accepts only the exact live `Agent` object registered under its id. ## Testing -Unit coverage pins creation defaults, exact-live-agent checks, compare-and-set rejection, every lifecycle transition, cap enforcement, clear/replacement, seeded replay and `SessionStore.fork()` inheritance, session-start disarming and active-goal rearming, FIFO deferred mutation reconciliation, listener containment, backward-clock clamping, strict record decoding, lifecycle continuity, source/content agreement, and sequential round attribution. A keyless Loader/stdio process test mounts the service and a lifecycle consumer through test-only `cordis.yml`, then reads the persisted JSONL externally to verify the model-visible snapshot and absence of an unrequested goal round. The package source is held to the repository's per-file 100% coverage gate. +Unit coverage pins creation defaults, exact-live-agent checks, compare-and-set rejection, every lifecycle transition, cap enforcement, clear/replacement, seeded replay and `SessionStore.fork()` inheritance, session-start and lifecycle-owner disarming, active-goal rearming, FIFO deferred mutation reconciliation, listener containment, backward-clock clamping, strict record decoding, lifecycle continuity, source/content agreement, and sequential round attribution. A keyless Loader/stdio process test mounts the service and a lifecycle consumer through test-only `cordis.yml`, then reads the persisted JSONL externally to verify the model-visible snapshot and absence of an unrequested goal round. The package source is held to the repository's per-file 100% coverage gate. ## Alternatives considered diff --git a/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md b/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md index 5fd35ee8fa..606268d80a 100644 --- a/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md +++ b/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md @@ -28,7 +28,7 @@ Status: implemented 最多只有一个当前目标。创建要求不存在未完成的当前目标,并始终生成该会话此前未使用过、修订号为一的 id;已完成目标可以被替换。其他每次变更都携带预期的 `GoalRef`,陈旧的 id 或修订号会被拒绝。仅当回合上限仍有余量时,停止阶段或已解除激活的活跃目标才能恢复;只有已接纳回合数达到上限后,才能标记预算受限。 -从任何种子构建的缓存都以未激活状态开始,每次 `agent/session-start` 边沿也会再次解除激活。因此,恢复和 fork 会保留持久目标与历史,但绝不会自行启动工作。后续人类提示词可由模型解释,其策略表面可以显式调用恢复操作并激活目标。 +从任何种子构建的缓存都以未激活状态开始,每次 `agent/session-start` 边沿也会再次解除激活。`GoalService.disarm(agent)` 还允许生命周期所有者移除进程内权限,而不写入会话事件、不改变修订号,也不发出 `goal/changed` 通知。因此,恢复、fork 和继续执行驱动器替换都会保留持久目标与历史,但绝不会自行启动工作。后续人类提示词可由模型解释,其策略表面可以显式调用恢复操作并激活目标。 ### 服务边界 @@ -36,7 +36,7 @@ Status: implemented ## 测试 -单元测试固定创建默认值、精确实时 agent 校验、比较并交换拒绝、所有生命周期转换、上限执行、清除与替换、种子回放和 `SessionStore.fork()` 继承、会话启动时解除激活与活跃目标重新激活、FIFO 延迟变更协调、监听器隔离、挂钟后退钳制、严格记录解码、生命周期连续性、来源与内容一致性,以及连续目标回合归属。无密钥 Loader/stdio 进程测试通过测试专用 `cordis.yml` 挂载服务与生命周期消费者,再从外部读取持久 JSONL,以验证模型可见快照以及不存在未经请求的目标回合。包源码受仓库逐文件 100% 覆盖率门禁约束。 +单元测试固定创建默认值、精确实时 agent 校验、比较并交换拒绝、所有生命周期转换、上限执行、清除与替换、种子回放和 `SessionStore.fork()` 继承、会话启动与生命周期所有者解除激活、活跃目标重新激活、FIFO 延迟变更协调、监听器隔离、挂钟后退钳制、严格记录解码、生命周期连续性、来源与内容一致性,以及连续目标回合归属。无密钥 Loader/stdio 进程测试通过测试专用 `cordis.yml` 挂载服务与生命周期消费者,再从外部读取持久 JSONL,以验证模型可见快照以及不存在未经请求的目标回合。包源码受仓库逐文件 100% 覆盖率门禁约束。 ## 考虑过的替代方案 diff --git a/docs/rfc/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml b/docs/rfc/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml new file mode 100644 index 0000000000..870c10b15d --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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: +# pnpm run verify-translation-pairing --write +2026-07-19-same-session-goal-round-driver.md: 886ea981056071e64f0bc037d969c5e33486a806 +2026-07-19-same-session-goal-round-driver.zh.md: be063c8a8b802a1e67c5fc84f16695a3d7d1f69e diff --git a/docs/rfc/implemented/feature/2026-07-19-same-session-goal-round-driver.md b/docs/rfc/implemented/feature/2026-07-19-same-session-goal-round-driver.md new file mode 100644 index 0000000000..886ea98105 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-same-session-goal-round-driver.md @@ -0,0 +1,100 @@ +# RFC: Same-session goal-round driver + +Status: implemented + +English | [中文](2026-07-19-same-session-goal-round-driver.zh.md) + +## Problem + +The goal domain can retain an objective and the model-facing tools can mutate its lifecycle, but neither should decide when another model turn begins. A continuation driver must bridge active goal state to the ordinary agent loop without adding goal-specific branches to `dsh-agent-loop`, inventing a second conversation, or treating every human turn as an autonomous iteration. + +That bridge has concurrency and durability obligations. Human input, cancellation, a goal edit, persistence failure, session restart, plugin unload, and a downstream prompt policy can all race a pending continuation. A naive `goal/changed -> agent.send()` listener can admit obsolete work, run alongside a human prompt, spend beyond the cap, or restart from replay without new authority. + +## Decision + +`@deepseek-ai/dsh-goal-session` in `packages/goal/goal-session/` is a policy plugin over `ctx.goals`, the public `Agent` interface, and durable session events. It imports no concrete agent-loop implementation. For each exact live `Agent`, it owns process-local scheduling state and may reserve at most one automatic round. + +The hierarchy is Goal → Goal Round → Turn → Step. A goal round is the outer continuation policy iteration; it becomes one goal-sourced session turn, and that turn can contain any number of ordinary model/tool steps. Human turns in the same session are not goal rounds and never increment `roundsStarted`. + +The plugin has no configuration. `maxGoalRounds` is resolved and persisted by `dsh-goal`, and the same-condition blocking threshold is resolved and prompted by `dsh-tool-goal`. Repeating those tunables in the driver would create multiple owners for one policy. + +### Reservation and admission + +When an agent is idle, has no competing queued work, and its current goal is `active` plus `armed`, the driver checkpoints pending goal mutations and rechecks every predicate after the await. If `roundsStarted` already equals `maxGoalRounds`, it records `budget-limited`. Otherwise it reserves the exact identity `{ goalId, revision, round: roundsStarted + 1 }` and the complete rendered prompt before calling `Agent.send()` with `GoalMessageSource`. The prompt JSON-quotes the objective so multiline or tag-like text remains an unambiguous data value inside the familiar frame. + +The `agent/prompt-submit` waterfall is the admission fence. A positive goal source is allowed only when it exactly matches the driver's pending identity and content, the live goal still has that id and revision, activation remains armed, and the round is still the next number. The plugin checks once before delegating and again after downstream hooks return. This second check prevents an async hook from editing or pausing the goal while still admitting the old prompt. + +Only the resulting `user/message` is an admitted round and advances the goal fold. A stale reservation becomes a durable `prompt/blocked` plus zero-step rejected turn, but the driver marks it stale and does not charge the round. A downstream policy rejection that is not caused by staleness blocks the goal rather than retrying around policy. + +### Human work and revision races + +`agent/queued` distinguishes the driver's complete accepted record from every other prompt. Ordinary work already queued before a reservation prevents scheduling. Ordinary work queued while an automatic prompt is pending makes that reservation stale, so a mixed batch admits the human prompt but rejects the automatic one. Ordinary work arriving after the goal round was admitted remains queued for its own next turn; continuation is reconsidered only when the agent later becomes idle. + +A goal mutation during a round advances its durable revision. Settlement of the older revision cannot overwrite that mutation. The driver discards the old attempt outcome, reads the new projection, and continues only if the new revision is still active and armed. This makes model-recorded completion, pause, block, and edit authoritative over the physical turn's later close reason. + +### Settlement + +The driver classifies one closed goal-owned turn as follows: + +| Turn result | Action | +|---|---| +| durable `completed` | continue while active/armed and under cap | +| broad cancellation / `aborted` | pause and disarm | +| `error` with code `RATE_LIMIT` | mark `usage-limited` | +| other `error` | block | +| `max-tokens` | block | +| non-stale `rejected` | block | +| failed durability checkpoint | disarm without changing durable phase | +| `disposed` or `interrupted` | disarm | +| plugin-added unknown result | block for inspection | + +No abnormal outcome requests an automatic retry. A later human prompt can ask to continue in any language; the model reads the stopped goal and uses the goal tool's resume action, which records a new revision and arms continuation. + +### Durability and cancellation seam + +Every `goal/changed` notification creates a checkpoint obligation. The driver awaits `ctx.sessions.flush(session)` before reserving work, then checks for a newer mutation, agent lifecycle change, or competing prompt. Turn-end flush failure is reported by the existing `agent/error` notification after `turn/end`; the driver associates it with the exact attempt and disarms before the next idle decision. + +Broad cancellation previously exposed only its effects after queues were cleared or the request aborted. The public agent vocabulary now includes observe-only `agent/cancel-requested(agent, reason)`. The concrete loop emits it for effective cancellation before either action; fused notification containment means a broken listener cannot veto cancellation. The goal driver uses this edge to clear its reservation and pause an active armed goal before the loop destroys the queued-work evidence. + +This is a coordination notification, not a second stop API. `Agent.cancel()` remains the only public broad cancellation verb, idle calls remain no-ops, and custom `Agent` implementations that claim the interface must honor the event ordering if consumers depend on it. + +### Process lifecycle + +`GoalService.disarm(agent)` removes only process-local activation. It writes no session event, changes no revision, and emits no goal mutation. The driver calls it while loading over existing agents, on durability uncertainty, and before teardown; a later `resume` is the durable activation edge visible to the model. + +The driver's event listeners and quiescent close are nested in one ordered Cordis effect. Cordis unloads sibling effects concurrently, so separate listener and cleanup registrations could remove the prompt fence while an async disposer was still draining. The composite effect first closes admission, disarms goals, cancels an admitted attempt, and awaits both agent and driver quiescence; only then does it unregister its listeners. + +An inbox acceptance can win the microtask race immediately before plugin unload begins. In that case the turn and even its first request may start and the round remains durably charged; once unload starts, cancellation aborts it, no following round is scheduled, and the goal remains active but disarmed. Pretending that already-observed admission never happened would corrupt replay accounting. + +## Testing + +The unit suite uses the real agent loop and session service with only the model scripted. It covers exact sequential admission and cap enforcement, load/resume inertness, every outcome classification, rate limiting, request errors, max tokens, downstream prompt veto, pre-admission and in-flight cancellation, human-input ordering, queued and downstream revision races, forged goal attribution, failed mutation and turn checkpoints, scheduler and custom-agent failures, session-start reset, exact lifecycle retirement, and queued/running plugin teardown. The new driver source has per-file 100% statement, branch, function, and line coverage. + +A keyless Loader/stdio process test mounts the real goal domain, goal tools, goal driver, agent loop, persistence, and deterministic adapter through `cordis.yml`. One human turn creates a two-round goal; round one stops normally; round two reads the exact ref and completes it. The external JSONL assertion proves one session, round sources `1, 2`, unchanged round revision, final complete revision, five model steps, and no extra request after the terminal completion tool. + +The core cancellation test proves notification order and containment: observers run only for effective cancellation, can queue replacement work before the inbox clear, cannot veto later observers by throwing, and an idle call emits nothing. + +## Alternatives considered + +- **Add a goal loop inside `dsh-agent-loop`** — rejected because the public queue, prompt, session, cancellation, and status seams are sufficient, and a concrete-loop branch would privilege one policy. +- **Use `agent/turn-continuation` to make every round another step** — rejected because a goal round is an outer policy iteration and must have its own durable user prompt, turn boundary, round count, and failure settlement. +- **Persist a pending reservation** — rejected because a crash cannot prove that queued process memory had reached admission; only the durable `user/message` consumes the round. +- **Retry provider or persistence errors automatically** — rejected because retry policy spends resources and needs explicit authority; stopped phases plus later human resume are simpler and observable. +- **Fork conversation history or spawn a fresh agent for every round** — rejected for this package because the goal is explicitly same-session work. Fresh-agent Ralph execution remains a separate workflow plugin built from subagent and workflow primitives. +- **Reuse every session turn as the round counter** — rejected because human clarification and unrelated work share the session but not the automatic-work budget. + +## Consequences + +- Goal continuation remains a removable plugin and the concrete loop gains only a generic observe-before-cancel notification. +- Replay can reconstruct every admitted round from its exact goal source and prompt; rejected reservations cannot create phantom budget use. +- Human messages and lifecycle mutations win documented races without corrupting the revision or counter. +- Resume and fork remain inert until semantic human intent causes the model to record a resume mutation. +- Conservative failure mapping can require manual continuation after transient failures, but it never hides an automatic retry. + +## Known limitations and deferred work + +- Completion evidence and semantic blocker equivalence remain model judgments. An independent evaluator, completion certificate, or verifier-driven stop policy is deferred to a separate policy plugin. +- This package does not provide Ralph-style fresh-agent attempts, context reset, cross-round evaluator feedback, or workflow-level parallelism; those belong to the separate Ralph workflow tool. +- Cordis unload begins asynchronously. An already accepted inbox item may enter one charged round and start one request before teardown cancellation takes effect; the closing drain prevents every subsequent round. +- `maxGoalRounds` is only an admitted-round limit. Token, currency, wall-clock, and provider-usage budgets require independent policy. +- A custom `Agent` implementation must produce the documented session events, status edges, cancel notification, and quiescence semantics; structural TypeScript compatibility alone cannot verify runtime ordering. diff --git a/docs/rfc/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md b/docs/rfc/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md new file mode 100644 index 0000000000..be063c8a8b --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md @@ -0,0 +1,100 @@ +# RFC: 同会话目标回合驱动器 + +Status: implemented + +[English](2026-07-19-same-session-goal-round-driver.md) | 中文 + +## 问题 + +目标领域可以保留目标,模型可见工具也可以变更其生命周期,但两者都不应决定下一个模型轮次何时开始。继续执行驱动器必须把活跃目标状态连接到普通 agent(智能体)循环,同时不能向 `dsh-agent-loop` 添加目标专用分支、创建第二段对话,也不能把每个人类轮次都视为自主迭代。 + +这层连接还承担并发与持久性义务。人类输入、取消、目标编辑、持久化失败、会话重启、插件卸载以及下游提示词策略都可能与待处理的继续执行发生竞争。简单的 `goal/changed -> agent.send()` 监听器可能接纳过期工作、与人类提示词同时运行、超出上限消耗资源,或在回放后未经新授权自行重启。 + +## 决策 + +位于 `packages/goal/goal-session/` 的 `@deepseek-ai/dsh-goal-session` 是构建在 `ctx.goals`、公共 `Agent` 接口和持久会话事件之上的策略插件。它不导入具体 agent-loop 实现。对于每个完全相同的实时 `Agent`,它维护进程内调度状态,并且最多保留一个自动回合预留。 + +层次关系为目标(Goal)→ 目标回合(Goal Round)→ 轮次(Turn)→ 步骤(Step)。目标回合是外层继续执行策略的一次迭代;它会成为一个归属于目标的会话轮次,而该轮次可以包含任意数量的普通模型或工具步骤。同一会话中的人类轮次不是目标回合,也绝不会增加 `roundsStarted`。 + +该插件没有配置项。`maxGoalRounds` 由 `dsh-goal` 解析并持久化;“相同阻塞条件”的门槛由 `dsh-tool-goal` 解析并写入提示词。若驱动器重复声明这些可调值,一个策略就会出现多个所有者。 + +### 预留与接纳 + +当 agent 空闲、没有竞争中的排队工作,且当前目标为 `active` 加 `armed` 时,驱动器会先检查点持久化待处理的目标变更,并在等待之后重新校验所有条件。若 `roundsStarted` 已等于 `maxGoalRounds`,它会记录 `budget-limited`;否则,它会先预留精确身份 `{ goalId, revision, round: roundsStarted + 1 }` 和完整渲染提示词,再以 `GoalMessageSource` 调用 `Agent.send()`。提示词用 JSON 引号编码目标描述,使多行或类似标签的文本在熟悉框架中仍是无歧义的数据值。 + +`agent/prompt-submit` 瀑布是接纳栅栏。正数目标来源只有在完全匹配驱动器待处理的身份和内容、实时目标仍具有相同 id 与修订号、激活态仍为 armed,并且该回合仍是下一个编号时才会获准。插件在委托下游监听器前检查一次,在下游返回后再检查一次。第二次检查防止异步钩子编辑或暂停目标后,旧提示词仍被接纳。 + +只有最终产生的 `user/message` 才是已接纳目标回合,并推进目标折叠。过期预留会生成持久的 `prompt/blocked` 和零步骤 rejected 轮次,但驱动器会把它标记为过期,不消耗回合数。若下游策略拒绝并非由过期导致,目标会进入 blocked,而不会绕过该策略自动重试。 + +### 人类工作与修订竞争 + +`agent/queued` 会区分驱动器自己的完整已接受记录与其他所有提示词。预留之前已经排队的普通工作会阻止调度;自动提示词待处理时进入的普通工作会使该预留过期,因此混合批次只接纳人类提示词而拒绝自动提示词。目标回合已经接纳后到达的普通工作会保留在队列中,成为下一个独立轮次;只有 agent 再次空闲后才重新考虑继续执行。 + +目标在回合内发生变更时会推进持久修订号。旧修订的结算不得覆盖该变更。驱动器会丢弃旧尝试的结果、读取新投影,并且只在新修订仍为 active 与 armed 时继续。因此,模型记录的完成、暂停、阻塞和编辑相对于物理轮次稍后的关闭原因具有最终权威。 + +### 结算 + +驱动器按下表分类一个已经关闭、归属于目标的轮次: + +| 轮次结果 | 动作 | +|---|---| +| 持久的 `completed` | 目标仍 active/armed 且未到上限时继续 | +| 广义取消 / `aborted` | 暂停并解除激活 | +| 代码为 `RATE_LIMIT` 的 `error` | 标记为 `usage-limited` | +| 其他 `error` | 阻塞 | +| `max-tokens` | 阻塞 | +| 非过期的 `rejected` | 阻塞 | +| 持久检查点失败 | 解除激活,但不改变持久阶段 | +| `disposed` 或 `interrupted` | 解除激活 | +| 插件新增的未知结果 | 阻塞并等待检查 | + +异常结果都不会请求自动重试。之后的人类提示词可以用任何语言要求继续;模型读取已停止目标并调用目标工具的 resume 动作,记录新修订并重新激活继续执行。 + +### 持久性与取消接缝 + +每次 `goal/changed` 通知都会产生一个检查点义务。驱动器在预留工作前等待 `ctx.sessions.flush(session)`,随后检查是否出现了更新的变更、agent 生命周期变化或竞争提示词。轮次结束时的 flush 失败会在 `turn/end` 之后通过现有 `agent/error` 通知报告;驱动器把它关联到精确尝试,并在下一次空闲决策前解除激活。 + +广义取消此前只在队列已清除或请求已中止后暴露结果。公共 agent 词汇现在新增只观察的 `agent/cancel-requested(agent, reason)`。具体循环仅在取消有效时发出该事件,并且发生在清除队列和中止步骤之前;融合通知会隔离失败,因此损坏的监听器不能否决取消。目标驱动器利用该边沿清除预留并暂停 active 且 armed 的目标,之后循环才销毁排队工作证据。 + +该通知是协调事件,不是第二个停止 API。`Agent.cancel()` 仍是唯一的公共广义取消动词,空闲调用仍是无操作;若消费者依赖此接缝,自定义 `Agent` 实现就必须满足该事件顺序。 + +### 进程生命周期 + +`GoalService.disarm(agent)` 只移除进程内激活态。它不写会话事件、不改变修订号,也不发出目标变更。驱动器在加载到已有 agent、持久性存在不确定性以及卸载前调用该方法;之后的 `resume` 才是模型可见的持久激活边沿。 + +驱动器的事件监听器和静止关闭嵌套在同一个有序 Cordis effect 中。Cordis 会并发卸载同级 effect;若监听器和清理分别注册,异步 disposer 仍在排空时提示词栅栏就可能已被移除。组合 effect 会先关闭接纳、解除目标激活、取消已接纳尝试,并等待 agent 与驱动器都达到静止;之后才注销监听器。 + +紧邻插件开始卸载前,收件箱接纳可能赢得微任务竞争。在这种情况下,轮次甚至首个请求都可能已经开始,且该回合仍会持久计费;卸载一旦开始,取消就会中止它,不会再调度后续回合,目标保持 active 但 disarmed。若假装已经观测到的接纳从未发生,就会破坏回放计数。 + +## 测试 + +单元测试使用真实 agent loop 与会话服务,只对模型编写脚本。覆盖内容包括精确连续接纳和上限执行、加载与恢复的惰性、所有结果分类、限流、请求错误、最大 token、下游提示词否决、接纳前与执行中取消、人类输入排序、排队时与下游修订竞争、伪造目标来源、变更与轮次检查点失败、调度器与自定义 agent 失败、会话启动重置、精确生命周期退出,以及排队中和运行中的插件卸载。新驱动器源码达到逐文件 100% 语句、分支、函数和行覆盖率。 + +无密钥 Loader/stdio 进程测试通过 `cordis.yml` 挂载真实目标领域、目标工具、目标驱动器、agent loop、持久化和确定性适配器。一个人类轮次创建两回合目标;第一回合正常停止;第二回合读取精确引用并完成目标。测试从外部检查 JSONL,证明只有一个会话、回合来源依次为 `1, 2`、回合修订号不变、最终完成修订正确、共有五个模型步骤,并且终止性的完成工具后没有额外请求。 + +核心取消测试固定通知顺序与隔离:只有有效取消才会通知;观察者可以在清空收件箱前排入替代工作;抛错不能阻止后续观察者;空闲调用不会发出事件。 + +## 考虑过的替代方案 + +- **在 `dsh-agent-loop` 内添加目标循环**——不予采纳,因为公共队列、提示词、会话、取消和状态接缝已经足够,具体循环分支还会赋予某种策略特权。 +- **使用 `agent/turn-continuation` 把每个回合变成另一个步骤**——不予采纳,因为目标回合是外层策略迭代,必须拥有自己的持久用户提示词、轮次边界、回合计数和失败结算。 +- **持久化待处理预留**——不予采纳,因为崩溃无法证明进程内队列已经达到接纳点;只有持久 `user/message` 才消耗回合。 +- **自动重试提供方或持久化错误**——不予采纳,因为重试会消耗资源,需要显式授权;停止阶段加之后的人类恢复更简单,也可观察。 +- **每回合 fork 对话历史或生成新 agent**——本包不采用,因为此目标明确属于同会话工作。新 agent 的 Ralph 执行仍是基于 subagent 与 workflow 原语的独立工作流插件。 +- **把每个会话轮次当作回合计数**——不予采纳,因为人类澄清和无关工作共享会话,但不共享自动工作预算。 + +## 后果 + +- 目标继续执行仍是可移除插件,具体循环只新增一个通用的“取消前观察”通知。 +- 回放可以从精确目标来源和提示词重建每个已接纳回合;被拒绝的预留不会产生虚假的预算消耗。 +- 人类消息和生命周期变更可以在有文档约束的竞争中胜出,而不破坏修订号或计数器。 +- 恢复和 fork 在语义上的人类意图促使模型记录 resume 变更之前始终保持惰性。 +- 保守的失败映射可能要求在暂时性错误后手动继续,但绝不会隐藏自动重试。 + +## 已知限制与延期工作 + +- 完成证据和阻塞条件的语义等价性仍由模型判断。独立评估器、完成证书或由验证器驱动的停止策略延期到独立策略插件。 +- 本包不提供 Ralph 风格的新 agent 尝试、上下文重置、跨回合评估反馈或工作流级并行;它们属于独立的 Ralph 工作流工具。 +- Cordis 卸载异步开始。已经被收件箱接受的条目可能先进入一个计费回合并启动一个请求,之后卸载取消才生效;关闭排空会阻止所有后续回合。 +- `maxGoalRounds` 只是已接纳回合上限。token、费用、挂钟时间和提供方使用预算需要独立策略。 +- 自定义 `Agent` 实现必须产生文档规定的会话事件、状态边沿、取消通知和静止语义;仅凭 TypeScript 结构兼容无法验证运行时顺序。 diff --git a/examples/echo-agent/tests/fixtures/goal/goal-session/cordis.yml b/examples/echo-agent/tests/fixtures/goal/goal-session/cordis.yml new file mode 100644 index 0000000000..9adc5c6909 --- /dev/null +++ b/examples/echo-agent/tests/fixtures/goal/goal-session/cordis.yml @@ -0,0 +1,25 @@ +# Test-only composition: one human turn creates a goal, then the driver runs two rounds. +- id: scripted-llm + name: './scripted-llm.ts' + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + +- id: goal + name: '@deepseek-ai/dsh-goal' + +- id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + +- id: goal-session + name: '@deepseek-ai/dsh-goal-session' + +- id: stdio-agent + name: '@deepseek-ai/dsh-stdio-demo' + config: + provider: goal-session-script + model: goal-session-script + persona: 'Execute the deterministic same-session goal-round proof.' + welcome: 'goal-session e2e ready.' + persistenceRoot: './.sessions' + workspaceContext: false diff --git a/examples/echo-agent/tests/fixtures/goal/goal-session/scripted-llm.ts b/examples/echo-agent/tests/fixtures/goal/goal-session/scripted-llm.ts new file mode 100644 index 0000000000..d7507553bf --- /dev/null +++ b/examples/echo-agent/tests/fixtures/goal/goal-session/scripted-llm.ts @@ -0,0 +1,104 @@ +/** Deterministic model for the same-session goal-round composition proof. */ + +import type { Context } from 'cordis' +import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, Message, StreamChunk } from '@deepseek-ai/dsh-llm' + +interface GoalState { + readonly id: string + readonly revision: number +} + +/** Text and position of the latest human or goal-round prompt. */ +function latestPrompt(messages: readonly Message[]): { index: number; text: string } { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index] + if (message?.role !== 'user') continue + const text = message.content + .filter(block => block.type === 'text' && !block.text.startsWith('')) + .map(block => block.type === 'text' ? block.text : '') + .join('\n') + if (text.includes('') || text === 'start') return { index, text } + } + return { index: -1, text: '' } +} + +/** Parse the latest durable goal snapshot retained in request history. */ +function latestGoal(messages: readonly Message[]): GoalState | undefined { + for (const message of [...messages].reverse()) { + for (const block of [...message.content].reverse()) { + if (block.type !== 'text' || !block.text.startsWith('')) continue + const json = block.text.slice(''.length, -''.length) + const value = JSON.parse(json) as { goal?: GoalState } + if (value.goal !== undefined) return value.goal + } + } + return undefined +} + +/** Tool names already recorded after the prompt that owns this physical turn. */ +function callsAfter(messages: readonly Message[], index: number): string[] { + return messages.slice(index + 1).flatMap(message => message.content) + .filter(block => block.type === 'tool-call') + .map(block => block.type === 'tool-call' ? block.name : '') +} + +/** Emit one deterministic tool call, optionally with visible progress text. */ +async function* toolCall(name: string, args: object, text?: string): AsyncIterable { + let index = 0 + if (text !== undefined) { + yield { type: 'block-start', index, blockType: 'text' } + yield { type: 'text-delta', index, text } + yield { type: 'block-end', index, block: { type: 'text', text } } + index += 1 + } + const id = CallId(`call-${name}`) + const raw = JSON.stringify(args) + yield { type: 'block-start', index, blockType: 'tool-call' } + yield { type: 'tool-call-delta', index, id, name, argumentsDelta: raw } + yield { type: 'block-end', index, block: { type: 'tool-call', id, name, arguments: raw } } + yield { type: 'finish', reason: { kind: 'tool-calls' } } +} + +/** Emit one terminal text response. */ +async function* textReply(text: string): AsyncIterable { + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text } + yield { type: 'block-end', index: 0, block: { type: 'text', text } } + yield { type: 'finish', reason: { kind: 'stop' } } +} + +class GoalSessionScriptAdapter extends LlmAdapter { + override stream(options: GenerateOptions): AsyncIterable { + const prompt = latestPrompt(options.messages) + const calls = callsAfter(options.messages, prompt.index) + if (prompt.text === 'start' && !calls.includes('create_goal')) { + return toolCall('create_goal', { + objective: 'Complete two deterministic same-session rounds', + max_goal_rounds: 2, + }) + } + if (prompt.text === 'start') return textReply('GOAL CREATED') + if (prompt.text.includes('Round: 1/2')) return textReply('ROUND ONE') + if (prompt.text.includes('Round: 2/2') && !calls.includes('get_goal')) { + return toolCall('get_goal', {}) + } + if (prompt.text.includes('Round: 2/2') && !calls.includes('update_goal')) { + const goal = latestGoal(options.messages) + if (goal === undefined) throw new Error('scripted goal state missing') + return toolCall('update_goal', { + goal_id: goal.id, + revision: goal.revision, + action: 'complete', + }, 'ROUND TWO COMPLETE') + } + return textReply('UNEXPECTED GOAL-SESSION REQUEST') + } +} + +export const name = 'goal-session-scripted-llm' +export const inject = ['llm'] + +export function apply(ctx: Context): void { + ctx.llm.registerAdapter(['goal-session-script'], new GoalSessionScriptAdapter()) +} diff --git a/examples/package.json b/examples/package.json index b1c8ac52cd..52cebaee31 100644 --- a/examples/package.json +++ b/examples/package.json @@ -16,6 +16,7 @@ "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-fs-policy": "workspace:*", "@deepseek-ai/dsh-goal": "workspace:*", + "@deepseek-ai/dsh-goal-session": "workspace:*", "@deepseek-ai/dsh-hooks-claude": "workspace:*", "@deepseek-ai/dsh-hooks-codex": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", diff --git a/knip.json b/knip.json index eb4eff3a9f..c97ea4edea 100644 --- a/knip.json +++ b/knip.json @@ -11,6 +11,7 @@ "entry": [ "echo-agent/src/*.ts", "echo-agent/tests/fixtures/goal/goal/seed-goal.ts", + "echo-agent/tests/fixtures/goal/goal-session/scripted-llm.ts", "echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts", "headless-agent/tests/fixtures/cli-mock-llm.ts", "tui-agent/tests/fixtures/tui-scripted-llm.ts", @@ -72,6 +73,10 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/goal/goal-session": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, "packages/goal/tool-goal": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 9262046683..bb3dc9c3a5 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -262,6 +262,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'get(agent: Agent): GoalView | undefined', jsDoc: '/**\n * Read the current goal for one exact live agent.\n * @param agent - owning live agent.\n * @returns a fresh view or `undefined` when no goal is current.\n * @throws {@link GoalError} when the agent is not the registry\'s live instance.\n */', }, + { + signature: 'disarm(agent: Agent): GoalView | undefined', + jsDoc: '/**\n * Remove process-local continuation authority without changing durable goal\n * phase or revision. Lifecycle owners use this before unloading a driver;\n * a later human-authorized {@link resume} records the new activation edge.\n * @param agent - owning live agent.\n * @returns a fresh disarmed view, or `undefined` when no goal is current.\n */', + }, { signature: 'create(agent: Agent, request: CreateGoalRequest): GoalView', jsDoc: '/**\n * Create and arm a goal. A completed goal may be replaced; every other\n * current phase must be cleared or resumed instead.\n * @param agent - owning live agent.\n * @param request - objective and optional round cap.\n * @returns the created live view.\n */', @@ -663,6 +667,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * A declarative agent entry failed before it could publish a live agent.\n * Consumers that buffer work for the configured identity use this\n * transient signal to reject that work instead of waiting forever. Normal\n * factory teardown suppresses failures from the cancelled startup attempt.\n * @param sessionId - exact shared agent/session identity that failed startup.\n * @param error - persistence, setup, or publication failure.\n * @mode emit\n */', summary: 'A declarative agent entry failed before it could publish a live agent.', }, + { + name: 'agent/cancel-requested', + mode: 'emit', + signature: '\'agent/cancel-requested\'(this: Scoped, agent: Agent, reason: string): void', + jsDoc: '/**\n * Effective broad cancellation was requested, before queued/steering work\n * is cleared or the active step is aborted. This observe-only notification\n * cannot veto cancellation; listener failures are contained.\n * @param agent - the agent whose current work is being cancelled.\n * @param reason - resolved cancellation reason, including the default.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'Effective broad cancellation was requested, before queued/steering work is cleared or the active step is aborted.', + }, { name: 'agent/created', mode: 'emit', diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index d604e2b16f..d5fea4cb88 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -54,7 +54,7 @@ The driver owns one agent for its lifetime and runs inside `ctx.agents.withIniti Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. A successful `agent/step-result` stores its transformed content; a rejected result records empty content before the original failure continues. The anchor retains exact chunk provenance (`[]` for a stream with no chunks) and usage when available, while empty content stays out of derived message history. -Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery observes a closed failed step, and a retry rebuilds the request from the durable log in a new numbered step. Cancellation clears pending work and aborts the current step without leaking to the next prompt; undispatched model tool calls receive synthetic `tool/call` and aborted result pairs. Terminal continuation stops remain authoritative through turn close and durability flush. +Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery observes a closed failed step, and a retry rebuilds the request from the durable log in a new numbered step. Effective cancellation resolves its reason and emits `agent/cancel-requested` before clearing pending work or aborting the current step; notification failures are contained, queued work added by an observer is included in the same broad clear, and idle cancellation emits nothing. Undispatched model tool calls receive synthetic `tool/call` and aborted result pairs. Terminal continuation stops remain authoritative through turn close and durability flush. Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, then drains accepted batch context before the turn closes through the normal abort path. diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 61b661c082..c0287d005b 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -333,13 +333,18 @@ export class ReactLoopAgent implements Agent { } cancel(reason?: string): void { + const resolvedReason = reason ?? 'cancelled' // Arm only for current work; an idle marker would cancel the next prompt. if (this._status === 'running' || this.currentAbort !== undefined || this.#inbox.hasQueued || this.#inbox.hasSteering) { this.cancelRequested = true // Capture the resolved reason for the marker-only windows (pre-step / // continuation). The mid-step path reads it from abort.signal.reason // below; the marker path reads it via the LoopHandle's cancelReason(). - this.cancelReason = reason ?? 'cancelled' + this.cancelReason = resolvedReason + // Coordination consumers must update their own state before this call + // clears the inbox or aborts the step. Notification failures are + // contained by the fused dispatcher and cannot veto cancellation. + agentEvents(this.loopCtx, this).emit('agent/cancel-requested', resolvedReason) } // Drop all pending queued + steering work (un-started prompts never run; the // cancelled turn's steering is not re-enqueued). Cleared directly even when @@ -349,7 +354,7 @@ export class ReactLoopAgent implements Agent { // Interrupt an in-flight step immediately (the running turn observes the // abort and ends `aborted`). The marker covers the windows where no step is // running (pre-step, continuation). - this.currentAbort?.abort(reason ?? 'cancelled') + this.currentAbort?.abort(resolvedReason) } /** diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 14e7cf8976..d4cbaa7375 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -7,7 +7,7 @@ * @module dsh-agent-loop/tests/cancel */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { type Message } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' @@ -55,6 +55,33 @@ function userTexts(agent: Agent): string[] { } describe('Agent.cancel()', () => { + it('notifies every observer before clearing work and contains listener failures', async () => { + const adapter = new MockAdapter([textResponse('must remain unused')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('cancel-event'), { provider: 'mock', model: 'mock' }) + const warned = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const seen: string[] = [] + ctx.on('agent/cancel-requested', (subject, reason) => { + if (subject !== agent) return + seen.push(`first:${reason}`) + subject.send([{ type: 'text', text: 'queued by cancel observer' }]) + throw new Error('observer failed') + }) + ctx.on('agent/cancel-requested', (subject, reason) => { + if (subject === agent) seen.push(`second:${reason}`) + }) + + send(agent, 'drop me') + agent.cancel() + await new Promise(resolve => setTimeout(resolve, 30)) + agent.cancel('idle no-op') + + expect(seen).toEqual(['first:cancelled', 'second:cancelled']) + expect(userTexts(agent)).toEqual([]) + expect(adapter.requests).toHaveLength(0) + expect(warned).toHaveBeenCalledWith(expect.stringContaining('agent/cancel-requested')) + }) + it('cancel() on an idle agent with nothing queued is a no-op; the next prompt runs (F2 leak guard)', async () => { const adapter = new MockAdapter([textResponse('reply')]) const ctx = await harness(adapter) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index bf5e563443..384ffeea93 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -44,7 +44,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves. -Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: a retry opens a new numbered step after the failed step closes. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale for scoped dispatch and terminal settlement is in the [agent-scope runtime-design RFC](../../../docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way). +Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: a retry opens a new numbered step after the failed step closes. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved reason, then clears queues and aborts; notification failures are contained and cannot veto the stop. The full rationale for scoped dispatch and terminal settlement is in the [agent-scope runtime-design RFC](../../../docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way). `PromptDecision.additionalContexts` is an array so every injected context keeps its own source, envelope, and metadata. A `ContinuationDecision` reason is narrower: it becomes a `steering/message`, not a `context/message`, and therefore carries only content and source. @@ -57,7 +57,7 @@ The handle every plugin programs against: - `agent.send(content, options?)` — queue a message; starts a turn when idle. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). - `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle - `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message`. `options.envelope` defaults to the canonical `` framing and may be `'raw'` when the caller owns a complete familiar frame; `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). -- `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op. +- `agent.cancel(reason?)` — cancel ALL pending work: an effective call emits `agent/cancel-requested` before it clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window). Observers may synchronize their own state but cannot veto cancellation. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op with no notification. - `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly. - `agent.session`, `agent.status`, `agent.options`, `agent.id` diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 702861f407..bb610f3a5b 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -120,9 +120,10 @@ export interface Agent { /** * Clear queued and steering work, including work waiting to start, and abort - * the active step. The supplied reason is preserved across pre-step and active - * cancellation windows, and `whenIdle()` resolves after cancellation reaches - * quiescence. Idle cancellation is a no-op and does not arm a later cancel. + * the active step. An effective call first emits `agent/cancel-requested` with + * the resolved reason. The supplied reason is preserved across pre-step and + * active cancellation windows, and `whenIdle()` resolves after cancellation + * reaches quiescence. Idle cancellation is a no-op and does not arm a later cancel. */ cancel(reason?: string): void @@ -173,6 +174,16 @@ declare module 'cordis' { * @mode emit */ 'agent/queued'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void + /** + * Effective broad cancellation was requested, before queued/steering work + * is cleared or the active step is aborted. This observe-only notification + * cannot veto cancellation; listener failures are contained. + * @param agent - the agent whose current work is being cancelled. + * @param reason - resolved cancellation reason, including the default. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ + 'agent/cancel-requested'(this: Scoped, agent: Agent, reason: string): void // ---- session lifecycle (emit) ---- /** diff --git a/packages/goal/README.md b/packages/goal/README.md index 1ab912b3c7..fd0b94f321 100644 --- a/packages/goal/README.md +++ b/packages/goal/README.md @@ -5,6 +5,7 @@ The goal family owns durable objective state independently of the model-facing t | Package | Role | ctx key | |---|---|---| | `goal/` | Event-sourced goal lifecycle, replay fold, compare-and-set mutations, and process-local activation | `ctx.goals` | +| `goal-session/` | Same-session goal-round admission, outcome mapping, and lifecycle race fencing | — | | `tool-goal/` | Model-facing read/create/update tools with execution-time authority checks | — | Goal state is part of the owning session log. Consumers depend on `dsh-goal`, not on the concrete agent loop; continuation behavior belongs in a separate plugin on the public agent seams. diff --git a/packages/goal/goal-session/README.md b/packages/goal/goal-session/README.md new file mode 100644 index 0000000000..0a73cfb75d --- /dev/null +++ b/packages/goal/goal-session/README.md @@ -0,0 +1,62 @@ +# @deepseek-ai/dsh-goal-session + +Same-session continuation driver for [`ctx.goals`](../goal/README.md). It turns an active, armed goal into sequential [goal rounds](../../../docs/glossary.md#goal-round) through the public `Agent` and session seams; the [same-session driver RFC](../../../docs/rfc/implemented/feature/2026-07-19-same-session-goal-round-driver.md) owns the race and lifecycle rationale. + +## Composition + +```yaml +- id: goal + name: '@deepseek-ai/dsh-goal' + +- id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + +- id: goal-session + name: '@deepseek-ai/dsh-goal-session' +``` + +The plugin has no tunable configuration. `maxGoalRounds` belongs to the goal definition, while the model-facing blocked threshold belongs to [`dsh-tool-goal`](../tool-goal/README.md); duplicating either value in the driver could produce divergent policy. + +## Round contract + +When an exact live agent is idle with an active, armed goal and remaining capacity, the driver first checkpoints pending goal mutations, then reserves `roundsStarted + 1` for the current `{ goalId, revision }`. It queues one `` prompt with `GoalMessageSource`. Admission through `agent/prompt-submit` verifies the complete queued record and current goal both before and after downstream prompt hooks; only the accepted `user/message` increments `roundsStarted`. A reservation rejected as stale does not consume the round number. + +One goal round owns one ordinary session turn, and that turn may contain several model/tool steps. Human messages remain ordinary turns and do not consume the goal cap. If human work enters the inbox before a reservation or joins its pending batch, automatic work yields until that work settles; a pending automatic prompt in a mixed batch is rejected and re-reserved only after the agent becomes idle. + +The retained prompt names the JSON-quoted objective and `round/maxGoalRounds`, treats the current workspace, tool results, and durable session state as authoritative, requires evidence before completion, and tells the model to leave the goal active when work remains. Quoting preserves multiline or tag-like objective text as data. Goal lifecycle mutations still require the independent authority checks in `dsh-tool-goal`. + +## Settlement policy + +| Durable turn outcome | Goal action | Automatic retry | +|---|---|---| +| `completed` with goal still active and armed | admit the next round, or mark `budget-limited` at the cap | yes | +| broad cancellation / `aborted` | `paused` | no | +| `error` with `RATE_LIMIT` | `usage-limited` | no | +| other `error`, `max-tokens`, or a non-stale prompt rejection | `blocked` | no | +| durability failure, disposal, interruption, or unknown future outcome | disarm or block for inspection | no | + +A goal mutation made during its round supersedes settlement of the older revision. Completion, pause, blocking, and edits therefore remain authoritative even if the physical turn closes afterward. No abnormal result is retried automatically. + +## Lifecycle and durability + +`goal/changed` creates a durability obligation. Before queuing work, the driver awaits `ctx.sessions.flush()` and rechecks both the goal revision and competing input after the await. A closing flush failure arrives through `agent/error`; the driver disarms before another round can start. + +Activation is never inherited when this plugin loads over an existing agent. `GoalService.disarm()` removes process-local authority without changing durable phase, revision, or history; explicit human-authorized resume records the later reactivation. The same rule applies after session resume and fork through the goal domain's `agent/session-start` handling. + +Cancellation is observe-before-act: the concrete loop emits `agent/cancel-requested` before clearing queues or aborting a step, allowing this plugin to pause and disarm the exact active goal. Plugin teardown closes admission, disarms every live goal, cancels an admitted round, and awaits the driver plus agent quiescence while its event fence remains installed. + +## Model Experience + +### Goal-round prompt + +**What the model sees**: Each admitted round is one retained user-role `` block naming the full objective and positive round number. Earlier human messages, goal-state snapshots, assistant output, and tool records remain in the same session history. + +**Token effect**: One fixed instruction block plus the objective is added per admitted round. Later requests resend retained rounds until compaction shadows them; no fresh agent or copied conversation prefix is created. + +## Known Limitations and Deferred Work + +- **No independent evaluator** — the model-facing goal policy decides when evidence is sufficient for completion and whether a blocker is semantically unchanged; evaluator-backed certification remains deferred. +- **Same-session execution only** — this package deliberately does not spawn a fresh agent, fork a session prefix, or implement Ralph-style independent attempts; that workflow belongs to its own plugin layer. +- **Accepted-queue unload race** — Cordis plugin unload is asynchronous. A goal prompt already accepted by the agent inbox can begin and consume its round before unload starts; teardown then cancels the request, disarms the goal, and awaits quiescence. No later round starts. +- **Round cap, not resource budget** — token, currency, time, and provider quota policies remain independent; `RATE_LIMIT` only maps an observed provider stop into `usage-limited`. +- **No abnormal auto-retry** — transient provider and persistence failures require a later human-authorized resume rather than an implicit retry policy. diff --git a/packages/goal/goal-session/package.json b/packages/goal/goal-session/package.json new file mode 100644 index 0000000000..c1493d017c --- /dev/null +++ b/packages/goal/goal-session/package.json @@ -0,0 +1,43 @@ +{ + "name": "@deepseek-ai/dsh-goal-session", + "description": "Race-fenced same-session goal-round driver", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-goal": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/goal/goal-session/src/index.ts b/packages/goal/goal-session/src/index.ts new file mode 100644 index 0000000000..09ff85cac4 --- /dev/null +++ b/packages/goal/goal-session/src/index.ts @@ -0,0 +1,443 @@ +/** + * Same-session goal-round driver over public agent, session, and goal seams. + * @module @deepseek-ai/dsh-goal-session + */ + +import { isDeepStrictEqual } from 'node:util' +import { FiberState } from 'cordis' +import type { Context } from 'cordis' +import type { Agent, PromptDecision } from '@deepseek-ai/dsh-agent' +import type { GoalMessageSource, GoalRef, GoalView } from '@deepseek-ai/dsh-goal' +import { assertNever } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' +import type { Session, SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' +import { classifyGoalRound } from './outcome.ts' +import type { GoalRoundOutcome } from './outcome.ts' +import { renderGoalRoundPrompt } from './prompt.ts' + +export { classifyGoalRound } from './outcome.ts' +export type { GoalRoundOutcome } from './outcome.ts' +export { renderGoalRoundPrompt } from './prompt.ts' + +export const name = 'goal-session' +export const inject = ['agents', 'goals', 'sessions'] + +const STALE_ROUND_REASON = 'stale goal-round reservation' + +/** Identity reserved before a goal continuation enters the agent inbox. */ +interface RoundIdentity { + readonly goalId: GoalRef['id'] + readonly revision: number + readonly round: number +} + +/** One queued or admitted attempt, retained until its physical turn settles. */ +interface RoundAttempt extends RoundIdentity { + readonly content: ContentBlock[] + phase: 'queued' | 'admitted' + turn: number | undefined + reason: TurnEndReason | undefined + rejectedReason: string | undefined + stale: boolean +} + +/** Serialized process-local scheduling state for one exact Agent lifecycle. */ +interface DriverState { + readonly agent: Agent + attempt: RoundAttempt | undefined + openTurn: number | undefined + competingQueued: boolean + needsCheckpoint: boolean + requested: boolean + run: Promise | undefined + stopping: boolean + readonly flushFailedTurns: Set +} + +/** Whether a source identifies an automatic, positive-numbered goal round. */ +function isGoalRoundSource(source: MessageSource): source is GoalMessageSource { + return source.kind === 'goal' && source.round > 0 +} + +/** Compare a source to one reserved identity. */ +function sameRound(source: GoalMessageSource, round: RoundIdentity): boolean { + return source.goalId === round.goalId + && source.revision === round.revision + && source.round === round.round +} + +/** Compare the complete queued record to the driver's reservation. */ +function sameQueued(content: ContentBlock[], source: MessageSource, attempt: RoundAttempt): boolean { + return isGoalRoundSource(source) && sameRound(source, attempt) && isDeepStrictEqual(content, attempt.content) +} + +/** Exact current ref for a view. */ +function goalRef(goal: GoalView): GoalRef { + return { id: goal.id, revision: goal.revision } +} + +/** Human-readable unexpected values for logs. */ +function renderThrown(value: unknown): string { + return value instanceof Error ? value.message : String(value) +} + +/** Install automatic same-session continuation and its race fences. */ +export function apply(ctx: Context): void { + const states = new Map() + + /** Create state for an exact currently live agent. */ + function stateFor(agent: Agent): DriverState { + const existing = states.get(agent) + if (existing !== undefined) return existing + const state: DriverState = { + agent, + attempt: undefined, + openTurn: undefined, + competingQueued: false, + needsCheckpoint: false, + requested: false, + run: undefined, + stopping: false, + flushFailedTurns: new Set(), + } + states.set(agent, state) + return state + } + + /** Read only when the exact Agent remains live. */ + function currentGoal(state: DriverState): GoalView | undefined { + if (ctx.agents.get(state.agent.id) !== state.agent || state.agent.status === 'disposed') return undefined + return ctx.goals.get(state.agent) + } + + /** Whether this exact lifecycle is quiescent with no competing prompt. */ + function readyToDrive(state: DriverState): boolean { + return ctx.fiber.state === FiberState.ACTIVE + && !state.stopping + && ctx.agents.get(state.agent.id) === state.agent + && state.agent.status === 'idle' + && !state.competingQueued + } + + /** Recheck every condition that an awaited checkpoint may have changed. */ + function readyAfterCheckpoint(state: DriverState): boolean { + return readyToDrive(state) && !state.needsCheckpoint + } + + /** Remove automatic authority while preserving the durable phase. */ + function disarm(state: DriverState): void { + try { + const goal = currentGoal(state) + if (goal?.activation === 'armed') ctx.goals.disarm(state.agent) + } catch (error: unknown) { + ctx.logger.warn(`goal-session: could not disarm agent "${state.agent.id}": ${renderThrown(error)}`) + } + } + + /** Apply one closed-round outcome only to the exact still-current revision. */ + function applyOutcome(state: DriverState, goal: GoalView, outcome: GoalRoundOutcome): void { + const ref = goalRef(goal) + switch (outcome.kind) { + case 'continue': + return + case 'pause': + ctx.goals.pause(state.agent, ref) + return + case 'usage-limited': + ctx.goals.markUsageLimited(state.agent, ref) + return + case 'blocked': + ctx.goals.block(state.agent, ref) + return + case 'disarm': + ctx.goals.disarm(state.agent) + return + /* v8 ignore next 2 -- GoalRoundOutcome is closed and every member is handled above */ + default: + assertNever(outcome, 'goal round outcome') + } + } + + /** Process a settled attempt, then reserve at most one next round. */ + async function drive(state: DriverState): Promise { + const { agent } = state + if (!readyToDrive(state)) return + + if (state.needsCheckpoint) { + state.needsCheckpoint = false + try { + await ctx.sessions.flush(agent.session) + } catch (error: unknown) { + ctx.logger.warn(`goal-session: durability checkpoint failed for agent "${agent.id}": ${renderThrown(error)}`) + const goal = currentGoal(state) + if (goal !== undefined) applyOutcome(state, goal, { kind: 'disarm', reason: 'durability-failed' }) + return + } + // A mutation or ordinary prompt may have arrived while the checkpoint + // was settling. Give it its own checkpoint / turn before reserving. + if (!readyAfterCheckpoint(state)) return + } + + const attempt = state.attempt + if (attempt !== undefined) { + if (attempt.reason === undefined) return + state.attempt = undefined + const turn = attempt.turn + /* v8 ignore next -- a closed attempt acquired its turn at turn/start */ + if (turn === undefined) throw new Error('settled goal-round attempt lacks a turn') + const durable = !state.flushFailedTurns.delete(turn) + const goal = currentGoal(state) + if (goal !== undefined && goal.id === attempt.goalId && goal.revision === attempt.revision + && goal.phase === 'active' && goal.activation === 'armed') { + const outcome = attempt.phase === 'queued' && attempt.rejectedReason !== undefined && !attempt.stale + ? { kind: 'blocked', reason: 'rejected', detail: attempt.rejectedReason } as const + : classifyGoalRound(attempt.reason, durable) + if (!attempt.stale) applyOutcome(state, goal, outcome) + } + if (!readyToDrive(state)) return + } + + const goal = currentGoal(state) + if (goal === undefined || goal.phase !== 'active' || goal.activation !== 'armed') return + if (goal.roundsStarted >= goal.maxGoalRounds) { + ctx.goals.markBudgetLimited(agent, goalRef(goal)) + return + } + + const round = goal.roundsStarted + 1 + const content = renderGoalRoundPrompt(goal, round) + const reservation: RoundAttempt = { + goalId: goal.id, + revision: goal.revision, + round, + content, + phase: 'queued', + turn: undefined, + reason: undefined, + rejectedReason: undefined, + stale: false, + } + state.attempt = reservation + try { + agent.send(content, { + source: { kind: 'goal', goalId: goal.id, revision: goal.revision, round }, + }) + } catch (error: unknown) { + state.attempt = undefined + ctx.logger.warn(`goal-session: could not queue round ${round} for agent "${agent.id}": ${renderThrown(error)}`) + const latest = currentGoal(state) + if (latest !== undefined && latest.id === goal.id && latest.revision === goal.revision + && latest.phase === 'active' && latest.activation === 'armed') { + ctx.goals.block(agent, goalRef(latest)) + } + } + } + + /** Coalesce triggers onto one agent-local serialized driver. */ + function requestDrive(state: DriverState): void { + /* v8 ignore next -- teardown may race a final trigger after synchronously closing admission */ + if (state.stopping) return + state.requested = true + if (state.run !== undefined) return + let run: Promise + try { + run = ctx.agents.withoutInitiator(async () => { + while (state.requested && !state.stopping) { + state.requested = false + try { + await drive(state) + } catch (error: unknown) { + ctx.logger.warn(`goal-session: driver failed for agent "${state.agent.id}": ${renderThrown(error)}`) + disarm(state) + } + } + }) + } catch (error: unknown) { + ctx.logger.warn(`goal-session: could not start driver for agent "${state.agent.id}": ${renderThrown(error)}`) + disarm(state) + return + } + state.run = run + const retire = (): void => { + state.run = undefined + if (state.requested && !state.stopping) requestDrive(state) + } + void run.then(retire, (error: unknown) => { + ctx.logger.warn(`goal-session: driver task rejected for agent "${state.agent.id}": ${renderThrown(error)}`) + disarm(state) + retire() + }) + } + + // One composite effect owns every listener and the quiescent close. Cordis + // unloads sibling effects concurrently; nesting makes the close run first + // and keeps the admission fence installed until its drain settles. + ctx.effect(function* () { + /** Mark a post-turn persistence failure before idle scheduling can run. */ + ctx.on('agent/error', (agent, turn) => { + const state = stateFor(agent) + const last = agent.session.events.at(-1) + if (last?.type !== 'turn/end' || last.data.turn !== turn) return + if (state.attempt?.turn === turn) state.flushFailedTurns.add(turn) + disarm(state) + }) + + ctx.on('agent/created', (agent) => { stateFor(agent) }) + ctx.on('agent/disposed', (agent) => { states.delete(agent) }) + ctx.on('agent/session-start', (agent) => { + const state = stateFor(agent) + state.attempt = undefined + state.openTurn = undefined + state.competingQueued = false + state.needsCheckpoint = false + state.flushFailedTurns.clear() + }) + ctx.on('agent/status', (agent, status) => { + const state = stateFor(agent) + if (status === 'disposed') { + state.stopping = true + return + } + if (status === 'idle') { + state.competingQueued = false + requestDrive(state) + } + }) + ctx.on('agent/queued', (agent, content, info) => { + const state = stateFor(agent) + const attempt = state.attempt + if (attempt !== undefined && sameQueued(content, info.source, attempt)) return + state.competingQueued = true + if (attempt?.phase === 'queued') attempt.stale = true + }) + ctx.on('agent/cancel-requested', (agent, reason) => { + const state = stateFor(agent) + state.attempt = undefined + state.competingQueued = false + const goal = currentGoal(state) + if (goal?.phase === 'active' && goal.activation === 'armed') { + applyOutcome(state, goal, { kind: 'pause', reason }) + } + }) + ctx.on('goal/changed', (agent) => { + const state = stateFor(agent) + state.needsCheckpoint = true + requestDrive(state) + }) + + ctx.on('session/event', (session: Session, event: SessionEvent) => { + const agent = ctx.agents.get(session.id) + if (agent === undefined || agent.session !== session) return + const state = stateFor(agent) + switch (event.type) { + case 'turn/start': + state.openTurn = event.data.turn + if (state.attempt !== undefined && isGoalRoundSource(event.data.trigger.source) + && sameRound(event.data.trigger.source, state.attempt)) { + state.attempt.turn = event.data.turn + } + return + case 'user/message': + if (state.attempt !== undefined && isGoalRoundSource(event.data.source) + && sameRound(event.data.source, state.attempt)) { + state.attempt.phase = 'admitted' + /* v8 ignore next -- this driver's admitted message always follows its observed turn/start */ + if (state.openTurn !== undefined) state.attempt.turn = state.openTurn + } + return + case 'prompt/blocked': + if (state.attempt !== undefined && state.attempt.phase === 'queued' + && isGoalRoundSource(event.data.source) && sameRound(event.data.source, state.attempt)) { + /* v8 ignore next -- this driver's rejected message always follows its observed turn/start */ + if (state.openTurn !== undefined) state.attempt.turn = state.openTurn + state.attempt.rejectedReason = event.data.reason + if (event.data.reason === STALE_ROUND_REASON) state.attempt.stale = true + } + return + case 'turn/end': + if (state.attempt?.turn === event.data.turn) state.attempt.reason = event.data.reason + /* v8 ignore next -- balanced live turns close the open turn just observed by this listener */ + if (state.openTurn === event.data.turn) state.openTurn = undefined + return + default: + return + } + }) + + /** Fail closed unless the queued prompt still owns the exact live revision. */ + function validReservation( + state: DriverState, + content: ContentBlock[], + source: GoalMessageSource, + ): boolean { + const attempt = state.attempt + const goal = currentGoal(state) + return ctx.fiber.state === FiberState.ACTIVE + && !state.stopping && attempt !== undefined && attempt.phase === 'queued' + && !attempt.stale && sameQueued(content, source, attempt) + && goal !== undefined && goal.id === source.goalId && goal.revision === source.revision + && goal.phase === 'active' && goal.activation === 'armed' + && source.round === goal.roundsStarted + 1 + } + + ctx.on('agent/prompt-submit', async (agent, content, source, next): Promise => { + if (!isGoalRoundSource(source)) return next() + const state = stateFor(agent) + let valid = false + try { + valid = validReservation(state, content, source) + } catch (error: unknown) { + ctx.logger.warn(`goal-session: admission check failed for agent "${agent.id}": ${renderThrown(error)}`) + disarm(state) + } + if (!valid) { + const attempt = state.attempt + if (attempt !== undefined && sameRound(source, attempt)) attempt.stale = true + return { kind: 'block', reason: STALE_ROUND_REASON } + } + const decision = await next() + if (decision.kind === 'block') return decision + try { + valid = validReservation(state, content, source) + } catch (error: unknown) { + ctx.logger.warn(`goal-session: post-admission check failed for agent "${agent.id}": ${renderThrown(error)}`) + disarm(state) + valid = false + } + if (!valid) { + const attempt = state.attempt + if (attempt !== undefined && sameRound(source, attempt)) attempt.stale = true + return { kind: 'block', reason: STALE_ROUND_REASON } + } + return decision + }) + + // Loading a lifecycle driver over existing agents never inherits hidden + // automatic authority from an earlier producer instance. + for (const agent of ctx.agents.list()) { + const state = stateFor(agent) + disarm(state) + } + + // Yielded after listener registration, so this close runs first and the + // composite effect removes listeners only after its promise settles. + yield async () => { + const waits: Promise[] = [] + for (const state of states.values()) { + state.stopping = true + disarm(state) + const attempt = state.attempt + if (attempt !== undefined) { + attempt.stale = true + if (attempt.phase === 'admitted' && state.agent.status === 'running') { + state.agent.cancel('goal-session driver disposed') + } + waits.push(state.agent.whenIdle()) + } + if (state.run !== undefined) waits.push(state.run) + } + await Promise.allSettled(waits) + states.clear() + } + }, 'goal-session lifecycle') +} diff --git a/packages/goal/goal-session/src/outcome.ts b/packages/goal/goal-session/src/outcome.ts new file mode 100644 index 0000000000..4660f1b7a5 --- /dev/null +++ b/packages/goal/goal-session/src/outcome.ts @@ -0,0 +1,48 @@ +/** Typed settlement policy for one admitted same-session goal round. */ + +import type { TurnEndReason } from '@deepseek-ai/dsh-session' + +/** Driver action derived from one closed goal-owned turn. */ +export type GoalRoundOutcome = + | { readonly kind: 'continue' } + | { readonly kind: 'pause'; readonly reason: string } + | { readonly kind: 'usage-limited'; readonly message: string } + | { + readonly kind: 'blocked' + readonly reason: 'error' | 'max-tokens' | 'rejected' | 'unknown' + readonly detail: string + } + | { readonly kind: 'disarm'; readonly reason: 'durability-failed' | 'disposed' | 'interrupted' } + +/** + * Classify one closed goal round without mutating goal state. + * @param reason - durable reason from the round's `turn/end`. + * @param durable - whether the closing flush reached its durability checkpoint. + * @returns the single driver action; no abnormal outcome requests an automatic retry. + */ +export function classifyGoalRound(reason: TurnEndReason, durable: boolean): GoalRoundOutcome { + if (!durable) return { kind: 'disarm', reason: 'durability-failed' } + const extensibleReason: { readonly kind: string } = reason + switch (reason.kind) { + case 'completed': + return { kind: 'continue' } + case 'aborted': + return { kind: 'pause', reason: reason.reason ?? 'cancelled' } + case 'error': + return reason.code === 'RATE_LIMIT' + ? { kind: 'usage-limited', message: reason.message } + : { kind: 'blocked', reason: 'error', detail: reason.message } + case 'max-tokens': + return { kind: 'blocked', reason: 'max-tokens', detail: 'model output reached max tokens' } + case 'rejected': + return { kind: 'blocked', reason: 'rejected', detail: reason.reason } + case 'disposed': + return { kind: 'disarm', reason: 'disposed' } + case 'interrupted': + return { kind: 'disarm', reason: 'interrupted' } + // TurnEndReason is merge-extensible. An unknown producer cannot opt into + // automatic retry merely by adding a tag; stop for inspection instead. + default: + return { kind: 'blocked', reason: 'unknown', detail: `unknown turn outcome: ${extensibleReason.kind}` } + } +} diff --git a/packages/goal/goal-session/src/prompt.ts b/packages/goal/goal-session/src/prompt.ts new file mode 100644 index 0000000000..9a2f69fcd8 --- /dev/null +++ b/packages/goal/goal-session/src/prompt.ts @@ -0,0 +1,26 @@ +/** Model-visible continuation prompt for one same-session goal round. */ + +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { GoalView } from '@deepseek-ai/dsh-goal' + +/** + * Render the complete goal-round instruction retained in session history. + * @param goal - exact active goal revision being admitted. + * @param round - next positive round number. + * @returns a fresh one-block prompt for `Agent.send()`. + */ +export function renderGoalRoundPrompt(goal: GoalView, round: number): ContentBlock[] { + return [{ + type: 'text', + text: '\n' + + `Objective: ${JSON.stringify(goal.objective)}\n` + + `Round: ${round}/${goal.maxGoalRounds}\n\n` + + 'Continue working toward the objective in this same session. Treat the current workspace, ' + + 'tool results, and durable session state as authoritative; inspect them instead of assuming ' + + 'earlier narration is still current. Make concrete progress and verify the result. Before ' + + 'claiming completion, gather evidence that the whole objective is achieved, read the current ' + + 'goal, and mark it complete. If work remains, leave the goal active for the next round. Follow ' + + 'the configured goal-tool policy before reporting a blocker.\n' + + '', + }] +} diff --git a/packages/goal/goal-session/tests/goal-session.e2e.ts b/packages/goal/goal-session/tests/goal-session.e2e.ts new file mode 100644 index 0000000000..d1152c5995 --- /dev/null +++ b/packages/goal/goal-session/tests/goal-session.e2e.ts @@ -0,0 +1,138 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { foldGoal } from '@deepseek-ai/dsh-goal' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' + +const binScript = fileURLToPath(new URL('../../../examples/stdio-demo/src/bin.ts', import.meta.url)) +const configPath = fileURLToPath(new URL( + '../../../../examples/echo-agent/tests/fixtures/goal/goal-session/cordis.yml', + import.meta.url, +)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) +const PROCESS_TIMEOUT_MS = 30_000 +const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000 + +let child: ChildProcessWithoutNullStreams | undefined +let workdir: string | undefined + +afterEach(async () => { + if (child !== undefined && child.exitCode === null) child.kill('SIGKILL') + child = undefined + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + workdir = undefined +}) + +/** Recursively locate persistence JSONL files in one temporary root. */ +async function jsonlFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const paths = await Promise.all(entries.map(async (entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) return jsonlFiles(path) + return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : [] + })) + return paths.flat() +} + +/** Run the complete deterministic human-turn plus two-round composition. */ +async function runComposition(): Promise<{ stdout: string; stderr: string }> { + workdir = await mkdtemp(join(tmpdir(), 'goal-session-e2e-')) + const cwd = workdir + return new Promise((resolve, reject) => { + const launch = resolveExampleLaunch({ + srcBin: binScript, + configArgs: [configPath], + tsconfigPath: repoTsconfig, + exposeInternals: true, + env: { + DSH_HOME: join(cwd, '.dsh'), + DSH_AGENTS_HOME: join(cwd, '.agents'), + }, + }) + const proc = spawn(launch.command, launch.args, { + cwd, + env: { ...process.env, ...launch.env }, + stdio: ['pipe', 'pipe', 'pipe'], + }) + child = proc + let stdout = '' + let stderr = '' + let inputClosed = false + proc.stdout.setEncoding('utf8') + proc.stdout.on('data', (chunk: string) => { + stdout += chunk + if (!inputClosed && stdout.includes('ROUND TWO COMPLETE') && stdout.includes('\n> ')) { + inputClosed = true + proc.stdin.end() + } + }) + proc.stderr.setEncoding('utf8') + proc.stderr.on('data', (chunk: string) => { stderr += chunk }) + + const timer = setTimeout(() => { + proc.kill('SIGKILL') + reject(new Error( + `goal-session e2e did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`, + )) + }, PROCESS_TIMEOUT_MS) + proc.on('exit', (code) => { + clearTimeout(timer) + if (code === 0) resolve({ stdout, stderr }) + else reject(new Error(`goal-session e2e exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }) + proc.on('error', (error) => { clearTimeout(timer); reject(error) }) + proc.stdin.write('start\n') + }) +} + +describe('same-session goal rounds through a real Loader, app, and stdio process', () => { + it('persists two exact rounds and stops the completion turn without another request', async () => { + const { stdout, stderr } = await runComposition() + expect(stderr).not.toContain('UNHANDLED') + expect(stdout).toContain('goal-session e2e ready.') + expect(stdout).toContain('GOAL CREATED') + expect(stdout).toContain('ROUND ONE') + expect(stdout).toContain('ROUND TWO COMPLETE') + + const logs = await jsonlFiles(join(workdir as string, '.sessions')) + expect(logs).toHaveLength(1) + const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n') + const events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent) + + const calls = events.filter(event => event.type === 'tool/call') + expect(calls.map(event => event.data.name)).toEqual(['create_goal', 'get_goal', 'update_goal']) + expect(events.filter(event => event.type === 'step/start')).toHaveLength(5) + expect(events.filter(event => event.type === 'tool/result').every(event => !event.data.isError)).toBe(true) + + const rounds = events.filter(event => event.type === 'user/message' + && event.data.source.kind === 'goal') + expect(rounds).toHaveLength(2) + const roundNumbers: number[] = [] + const revisions: number[] = [] + const prompts: string[] = [] + for (const event of events) { + if (event.type !== 'user/message' || event.data.source.kind !== 'goal') continue + roundNumbers.push(event.data.source.round) + revisions.push(event.data.source.revision) + prompts.push(event.data.content.find(block => block.type === 'text')?.text ?? '') + } + expect(roundNumbers).toEqual([1, 2]) + expect(revisions).toEqual([1, 1]) + expect(prompts[0]).toContain('Round: 1/2') + expect(prompts[1]).toContain('Round: 2/2') + + expect(foldGoal(events)).toMatchObject({ + goal: { + objective: 'Complete two deterministic same-session rounds', + phase: 'complete', + revision: 2, + maxGoalRounds: 2, + }, + roundsStarted: 2, + }) + }, TEST_TIMEOUT_MS) +}) diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts new file mode 100644 index 0000000000..13d6d9bf3f --- /dev/null +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -0,0 +1,648 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { agentEvents } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import GoalService, { GoalId } from '@deepseek-ai/dsh-goal' +import type { GoalView } from '@deepseek-ai/dsh-goal' +import { LlmAdapter } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { TurnEndReason } from '@deepseek-ai/dsh-session' +import * as goalSession from '../src/index.ts' + +type ScriptEntry = StreamChunk[] | Error | 'hang' | ((options: GenerateOptions) => StreamChunk[]) + +/** Small request-recording adapter with controllable failure and cancellation. */ +class ScriptedAdapter extends LlmAdapter { + readonly requests: GenerateOptions[] = [] + + constructor(private readonly script: ScriptEntry[]) { + super() + } + + override async * stream(options: GenerateOptions): AsyncIterable { + this.requests.push(options) + const entry = this.script.shift() + if (entry === undefined) throw new Error('ScriptedAdapter: script exhausted') + if (entry instanceof Error) throw entry + if (entry === 'hang') { + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: 'partial' } + await new Promise((_resolve, reject) => { + if (options.signal?.aborted) { + reject(new Error('aborted')) + return + } + options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) + }) + return + } + const chunks = typeof entry === 'function' ? entry(options) : entry + for (const chunk of chunks) yield chunk + } +} + +/** One successful text response. */ +function textResponse(text: string): StreamChunk[] { + return [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'block-end', index: 0, block: { type: 'text', text } }, + { type: 'finish', reason: { kind: 'stop' } }, + ] +} + +/** One successful response cut off at the model output limit. */ +function maxTokensResponse(text: string): StreamChunk[] { + return [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'block-end', index: 0, block: { type: 'text', text } }, + { type: 'finish', reason: { kind: 'max-tokens' } }, + ] +} + +/** Complete request history as a single string for ordering assertions. */ +function requestText(request: GenerateOptions): string { + return request.messages + .flatMap(message => message.content) + .filter(block => block.type === 'text') + .map(block => block.text) + .join('\n') +} + +interface Harness { + readonly ctx: Context + readonly adapter: ScriptedAdapter + readonly agent: Agent + readonly driver: Awaited> +} + +const contexts: Context[] = [] + +afterEach(async () => { + await Promise.allSettled(contexts.splice(0).map(context => context.fiber.dispose())) +}) + +/** Mount a real loop with only its model scripted. */ +async function harness(script: ScriptEntry[]): Promise { + const ctx = new Context() + contexts.push(ctx) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(GoalService) + const driver = await ctx.plugin(goalSession) + await ctx.plugin(AgentLoop, { agents: [] }) + const adapter = new ScriptedAdapter(script) + ctx.llm.registerAdapter(['mock'], adapter) + const agent = ctx.agentLoop.create(SessionId(`goal-session-${Math.random()}`), { + provider: 'mock', + model: 'mock', + }) + return { ctx, adapter, agent, driver } +} + +/** Await a stable goal projection selected by the caller. */ +async function waitForGoal( + ctx: Context, + agent: Agent, + predicate: (goal: GoalView | undefined) => boolean, +): Promise { + await vi.waitFor(() => { + expect(predicate(ctx.goals.get(agent))).toBe(true) + }) + return ctx.goals.get(agent) +} + +/** Await a specific number of dispatched model requests. */ +async function waitForRequests(adapter: ScriptedAdapter, count: number): Promise { + await vi.waitFor(() => { + expect(adapter.requests).toHaveLength(count) + }) +} + +describe('goal-round outcome policy', () => { + it.each([ + [{ kind: 'completed' }, true, { kind: 'continue' }], + [{ kind: 'aborted', reason: 'operator stopped' }, true, { kind: 'pause', reason: 'operator stopped' }], + [{ kind: 'aborted' }, true, { kind: 'pause', reason: 'cancelled' }], + [{ kind: 'error', step: 1, message: 'slow down', code: 'RATE_LIMIT' }, true, + { kind: 'usage-limited', message: 'slow down' }], + [{ kind: 'error', step: 1, message: 'broken' }, true, + { kind: 'blocked', reason: 'error', detail: 'broken' }], + [{ kind: 'max-tokens' }, true, + { kind: 'blocked', reason: 'max-tokens', detail: 'model output reached max tokens' }], + [{ kind: 'rejected', reason: 'policy' }, true, + { kind: 'blocked', reason: 'rejected', detail: 'policy' }], + [{ kind: 'disposed' }, true, { kind: 'disarm', reason: 'disposed' }], + [{ kind: 'interrupted' }, true, { kind: 'disarm', reason: 'interrupted' }], + [{ kind: 'completed' }, false, { kind: 'disarm', reason: 'durability-failed' }], + [{ kind: 'future-outcome' } as unknown as TurnEndReason, true, + { kind: 'blocked', reason: 'unknown', detail: 'unknown turn outcome: future-outcome' }], + ] as const)('maps %j without abnormal automatic retry', (reason, durable, expected) => { + expect(goalSession.classifyGoalRound(reason, durable)).toEqual(expected) + }) + + it('renders the objective, round budget, authority boundary, and completion protocol', () => { + const goal: GoalView = { + id: GoalId('goal-prompt'), + revision: 4, + objective: 'Ship verified support', + phase: 'active', + maxGoalRounds: 9, + roundsStarted: 2, + createdAt: 1, + updatedAt: 2, + activation: 'armed', + } + const prompt = goalSession.renderGoalRoundPrompt(goal, 3) + expect(prompt).toHaveLength(1) + const block = prompt[0] + if (block?.type !== 'text') throw new Error('expected a text goal-round prompt') + expect(block.text).toMatch( + /\nObjective: "Ship verified support"\nRound: 3\/9[\s\S]*current workspace[\s\S]*verify[\s\S]*mark it complete/, + ) + }) + + it('quotes multiline or tag-like objective text as one unambiguous data value', () => { + const goal: GoalView = { + id: GoalId('goal-escaped-prompt'), + revision: 1, + objective: 'first line\n second line', + phase: 'active', + maxGoalRounds: 2, + roundsStarted: 0, + createdAt: 1, + updatedAt: 1, + activation: 'armed', + } + const block = goalSession.renderGoalRoundPrompt(goal, 1)[0] + if (block?.type !== 'text') throw new Error('expected a text goal-round prompt') + expect(block.text).toContain('Objective: "first line\\n second line"') + expect(block.text.match(/\n<\/goal_round>/g)).toHaveLength(1) + }) +}) + +describe('same-session goal driving', () => { + it('admits exact numbered rounds until the durable round cap', async () => { + const test = await harness([textResponse('round one'), textResponse('round two')]) + const created = test.ctx.goals.create(test.agent, { objective: 'finish twice', maxGoalRounds: 2 }) + + const final = await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'budget-limited') + + expect(final).toMatchObject({ id: created.id, roundsStarted: 2, activation: 'disarmed' }) + expect(test.adapter.requests).toHaveLength(2) + const rounds: number[] = [] + for (const event of test.agent.session.events) { + if (event.type === 'user/message' && event.data.source.kind === 'goal') { + rounds.push(event.data.source.round) + } + } + expect(rounds).toEqual([1, 2]) + expect(requestText(test.adapter.requests[0]!)).toContain('Round: 1/2') + expect(requestText(test.adapter.requests[1]!)).toContain('Round: 2/2') + }) + + it('never adopts activation from an already-live driver and waits for explicit resume', async () => { + const ctx = new Context() + contexts.push(ctx) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(GoalService) + await ctx.plugin(AgentLoop, { agents: [] }) + const adapter = new ScriptedAdapter([textResponse('after resume')]) + ctx.llm.registerAdapter(['mock'], adapter) + const agent = ctx.agentLoop.create(SessionId('goal-session-hot-load'), { provider: 'mock', model: 'mock' }) + const created = ctx.goals.create(agent, { objective: 'wait for a human', maxGoalRounds: 1 }) + + await ctx.plugin(goalSession) + await Promise.resolve() + expect(ctx.goals.get(agent)).toMatchObject({ phase: 'active', activation: 'disarmed', revision: 1 }) + expect(adapter.requests).toHaveLength(0) + + ctx.goals.resume(agent, created) + await waitForGoal(ctx, agent, goal => goal?.phase === 'budget-limited') + expect(adapter.requests).toHaveLength(1) + }) + + it.each([ + ['rate limit', Object.assign(new Error('slow down'), { code: 'RATE_LIMIT' }), 'usage-limited'], + ['request error', new Error('provider broke'), 'blocked'], + ['max tokens', maxTokensResponse('unfinished'), 'blocked'], + ] as const)('stops after a %s without an automatic retry', async (_label, response, phase) => { + const test = await harness([response]) + test.ctx.goals.create(test.agent, { objective: 'stop safely', maxGoalRounds: 8 }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === phase) + + expect(goal).toMatchObject({ roundsStarted: 1, activation: 'disarmed' }) + expect(test.adapter.requests).toHaveLength(1) + }) + + it('maps a downstream prompt veto to blocked without admitting the round', async () => { + const test = await harness([]) + test.ctx.on('agent/prompt-submit', (_agent, _content, source, next) => source.kind === 'goal' + ? Promise.resolve({ kind: 'block', reason: 'deployment policy' }) + : next()) + test.ctx.goals.create(test.agent, { objective: 'respect policy' }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked') + + expect(goal?.roundsStarted).toBe(0) + expect(test.adapter.requests).toHaveLength(0) + expect(test.agent.session.events.some(event => event.type === 'prompt/blocked' + && event.data.reason === 'deployment policy')).toBe(true) + }) + + it('does not reserve again when a stopped-goal observer queues ordinary work', async () => { + const test = await harness([textResponse('human follow-up')]) + test.ctx.on('agent/prompt-submit', (_agent, _content, source, next) => source.kind === 'goal' + ? Promise.resolve({ kind: 'block', reason: 'stop this round' }) + : next()) + test.ctx.on('goal/changed', (agent, change) => { + if (change.operation === 'block') agent.send([{ type: 'text', text: 'inspect the blocker' }]) + }) + test.ctx.goals.create(test.agent, { objective: 'stop and inspect' }) + + await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked') + await waitForRequests(test.adapter, 1) + await test.agent.whenIdle() + + expect(requestText(test.adapter.requests[0]!)).toContain('inspect the blocker') + }) + + it('pauses and drops a reserved round when cancellation lands before admission', async () => { + const test = await harness([]) + const cancel = test.ctx.on('agent/queued', (agent, _content, info) => { + if (agent === test.agent && info.source.kind === 'goal') { + cancel() + agent.cancel('operator cancelled pending goal') + } + }) + test.ctx.goals.create(test.agent, { objective: 'do not start yet' }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'paused') + + expect(goal).toMatchObject({ roundsStarted: 0, activation: 'disarmed' }) + expect(test.adapter.requests).toHaveLength(0) + expect(test.agent.session.events.some(event => event.type === 'user/message' + && event.data.source.kind === 'goal')).toBe(false) + }) + + it('pauses an admitted round when cancellation aborts an active step', async () => { + const test = await harness(['hang']) + test.ctx.goals.create(test.agent, { objective: 'stop in flight' }) + await waitForRequests(test.adapter, 1) + + test.agent.cancel('operator stopped active goal') + await test.agent.whenIdle() + const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'paused') + + expect(goal).toMatchObject({ roundsStarted: 1, activation: 'disarmed' }) + expect(test.adapter.requests).toHaveLength(1) + }) + + it('lets already-queued human work finish before reserving the next round', async () => { + const test = await harness([textResponse('human answer'), textResponse('goal answer')]) + test.ctx.goals.create(test.agent, { objective: 'continue after the human', maxGoalRounds: 1 }) + test.agent.send([{ type: 'text', text: 'human goes first' }]) + + await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'budget-limited') + + expect(test.adapter.requests).toHaveLength(2) + expect(requestText(test.adapter.requests[0]!)).toContain('human goes first') + expect(requestText(test.adapter.requests[0]!)).not.toContain('') + expect(requestText(test.adapter.requests[1]!)).toContain('') + }) + + it('makes a reserved round stale when a listener queues human work behind it', async () => { + const test = await harness([textResponse('human batch'), textResponse('later goal')]) + let inserted = false + test.ctx.on('agent/queued', (agent, _content, info) => { + if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return + inserted = true + agent.send([{ type: 'text', text: 'human joined the pending batch' }]) + }) + test.ctx.goals.create(test.agent, { objective: 'yield to nested human input', maxGoalRounds: 1 }) + + await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'budget-limited') + + expect(test.adapter.requests).toHaveLength(2) + expect(requestText(test.adapter.requests[0]!)).toContain('human joined the pending batch') + expect(requestText(test.adapter.requests[0]!)).not.toContain('') + expect(requestText(test.adapter.requests[1]!)).toContain('') + }) + + it('blocks a queued reservation made stale by a goal edit and continues the new revision', async () => { + const test = await harness([textResponse('new revision')]) + let edited = false + test.ctx.on('agent/queued', (agent, _content, info) => { + if (agent !== test.agent || info.source.kind !== 'goal' || edited) return + edited = true + const current = test.ctx.goals.get(agent) + if (current === undefined) throw new Error('missing goal during queued edit') + test.ctx.goals.edit(agent, current, { objective: 'new objective' }) + }) + test.ctx.goals.create(test.agent, { objective: 'old objective', maxGoalRounds: 1 }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'budget-limited') + + expect(goal).toMatchObject({ revision: 3, objective: 'new objective', roundsStarted: 1 }) + const blocked = test.agent.session.events.find(event => event.type === 'prompt/blocked') + expect(blocked?.type === 'prompt/blocked' ? blocked.data.reason : undefined) + .toBe('stale goal-round reservation') + const admitted = test.agent.session.events.find(event => event.type === 'user/message' + && event.data.source.kind === 'goal') + expect(admitted?.type === 'user/message' && admitted.data.source.kind === 'goal' + ? admitted.data.source.revision + : undefined).toBe(2) + }) + + it('rechecks revision after downstream prompt hooks before admitting', async () => { + const test = await harness([textResponse('new revision')]) + let edited = false + test.ctx.on('agent/prompt-submit', (agent, _content, source, next) => { + if (source.kind === 'goal' && !edited) { + edited = true + const current = test.ctx.goals.get(agent) + if (current === undefined) throw new Error('missing goal during prompt edit') + test.ctx.goals.edit(agent, current, { objective: 'edited downstream' }) + } + return next() + }) + test.ctx.goals.create(test.agent, { objective: 'edit during admission', maxGoalRounds: 1 }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'budget-limited') + + expect(goal).toMatchObject({ objective: 'edited downstream', roundsStarted: 1 }) + expect(test.adapter.requests).toHaveLength(1) + expect(test.agent.session.events.some(event => event.type === 'prompt/blocked' + && event.data.reason === 'stale goal-round reservation')).toBe(true) + }) + + it('disarms without dispatch when a durability checkpoint fails', async () => { + const test = await harness([]) + test.ctx.on('session/flush', () => Promise.reject(new Error('disk unavailable'))) + test.ctx.goals.create(test.agent, { objective: 'do not outrun storage' }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.activation === 'disarmed') + + expect(goal).toMatchObject({ phase: 'active', roundsStarted: 0 }) + expect(test.adapter.requests).toHaveLength(0) + }) + + it('contains a checkpoint failure after a clear notification leaves no current goal', async () => { + const test = await harness([]) + test.ctx.on('session/flush', () => Promise.reject(new Error('clear checkpoint failed'))) + agentEvents(test.ctx, test.agent).emit('goal/changed', { + operation: 'clear', + ref: { id: GoalId('cleared-goal'), revision: 2 }, + }) + await new Promise((resolve) => { setImmediate(resolve) }) + + expect(test.ctx.goals.get(test.agent)).toBeUndefined() + expect(test.adapter.requests).toHaveLength(0) + }) + + it('disarms an admitted round whose closing durability checkpoint fails', async () => { + const test = await harness([textResponse('not durable')]) + test.ctx.on('session/flush', (session) => { + const lastStart = session.events.findLast(event => event.type === 'turn/start') + if (lastStart?.type === 'turn/start' && lastStart.data.trigger.kind === 'message' + && lastStart.data.trigger.source.kind === 'goal') { + return Promise.reject(new Error('round flush failed')) + } + }) + test.ctx.goals.create(test.agent, { objective: 'checkpoint the result' }) + + const goal = await waitForGoal( + test.ctx, + test.agent, + current => current?.roundsStarted === 1 && current.activation === 'disarmed', + ) + + expect(goal?.phase).toBe('active') + expect(test.adapter.requests).toHaveLength(1) + }) + + it('blocks the goal when a custom agent rejects the otherwise valid send', async () => { + const test = await harness([]) + vi.spyOn(test.agent, 'send').mockImplementationOnce(() => { + throw new Error('queue rejected') + }) + test.ctx.goals.create(test.agent, { objective: 'handle queue failure' }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked') + + expect(goal).toMatchObject({ roundsStarted: 0, activation: 'disarmed' }) + expect(test.adapter.requests).toHaveLength(0) + }) + + it('preserves a custom agent side effect when send disarms before throwing', async () => { + const test = await harness([]) + vi.spyOn(test.agent, 'send').mockImplementationOnce(() => { + test.ctx.goals.disarm(test.agent) + throw new Error('queue rejected after disarm') + }) + test.ctx.goals.create(test.agent, { objective: 'preserve the newer activation state' }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.activation === 'disarmed') + + expect(goal).toMatchObject({ phase: 'active', roundsStarted: 0 }) + expect(test.adapter.requests).toHaveLength(0) + }) + + it('contains a driver read failure and removes continuation authority', async () => { + const test = await harness([]) + let flushes = 0 + test.ctx.on('session/flush', () => { + flushes += 1 + if (flushes !== 2) return + vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => { + throw new Error('corrupt projection') + }) + }) + test.ctx.goals.create(test.agent, { objective: 'fail the driver closed' }) + await new Promise((resolve) => { setImmediate(resolve) }) + + const goal = test.ctx.goals.get(test.agent) + + expect(goal?.phase).toBe('active') + expect(test.adapter.requests).toHaveLength(0) + }) + + it('contains synchronous scheduler startup failure', async () => { + const test = await harness([]) + vi.spyOn(test.ctx.agents, 'withoutInitiator').mockImplementationOnce(() => { + throw 'scheduler closed' + }) + test.ctx.goals.create(test.agent, { objective: 'fail startup closed' }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.activation === 'disarmed') + + expect(goal?.phase).toBe('active') + expect(test.adapter.requests).toHaveLength(0) + }) + + it('contains an asynchronously rejected scheduler task', async () => { + const test = await harness([]) + vi.spyOn(test.ctx.agents, 'withoutInitiator').mockImplementationOnce( + () => Promise.reject(new Error('scheduler task rejected')), + ) + test.ctx.goals.create(test.agent, { objective: 'fail task closed' }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.activation === 'disarmed') + + expect(goal?.phase).toBe('active') + expect(test.adapter.requests).toHaveLength(0) + }) + + it('fails a pre-admission read closed even when the first disarm attempt throws', async () => { + const test = await harness([textResponse('retry after containment')]) + let armed = true + test.ctx.on('agent/queued', (agent, _content, info) => { + if (agent !== test.agent || info.source.kind !== 'goal' || !armed) return + armed = false + vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => { + throw new Error('admission projection failed') + }) + vi.spyOn(test.ctx.goals, 'disarm').mockImplementationOnce(() => { + throw 'disarm failed' + }) + }) + test.ctx.goals.create(test.agent, { objective: 'retry stale admission', maxGoalRounds: 1 }) + + await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'budget-limited') + + expect(test.adapter.requests).toHaveLength(1) + expect(test.agent.session.events.some(event => event.type === 'prompt/blocked' + && event.data.reason === 'stale goal-round reservation')).toBe(true) + }) + + it('fails a post-hook read closed before the prompt can enter history', async () => { + const test = await harness([]) + let armed = true + test.ctx.on('agent/prompt-submit', (_agent, _content, source, next) => { + if (source.kind === 'goal' && armed) { + armed = false + vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => { + throw new Error('post-hook projection failed') + }) + } + return next() + }) + test.ctx.goals.create(test.agent, { objective: 'block post-hook failure' }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.activation === 'disarmed') + + expect(goal).toMatchObject({ phase: 'active', roundsStarted: 0 }) + expect(test.adapter.requests).toHaveLength(0) + }) + + it('blocks forged goal attribution without touching an absent reservation', async () => { + const test = await harness([]) + test.agent.send([{ type: 'text', text: 'forged automatic work' }], { + source: { kind: 'goal', goalId: GoalId('forged-goal'), revision: 1, round: 1 }, + }) + await test.agent.whenIdle() + + expect(test.adapter.requests).toHaveLength(0) + expect(test.agent.session.events.some(event => event.type === 'prompt/blocked' + && event.data.reason === 'stale goal-round reservation')).toBe(true) + }) + + it('does not invent goal state when ordinary queued work is cancelled', async () => { + const test = await harness([]) + test.agent.send([{ type: 'text', text: 'cancel ordinary work' }]) + test.agent.cancel('ordinary cancellation') + await test.agent.whenIdle() + + expect(test.ctx.goals.get(test.agent)).toBeUndefined() + expect(test.adapter.requests).toHaveLength(0) + }) + + it('blocks admission when downstream cancellation clears the reservation', async () => { + const test = await harness([]) + let cancelled = false + test.ctx.on('agent/prompt-submit', (agent, _content, source, next) => { + if (source.kind === 'goal' && !cancelled) { + cancelled = true + agent.cancel('cancel from downstream admission policy') + } + return next() + }) + test.ctx.goals.create(test.agent, { objective: 'cancel during admission' }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'paused') + await test.agent.whenIdle() + + expect(goal?.roundsStarted).toBe(0) + expect(test.adapter.requests).toHaveLength(0) + }) + + it('disarms and cancels an admitted round before driver teardown completes', async () => { + const test = await harness(['hang']) + test.ctx.goals.create(test.agent, { objective: 'survive plugin unload' }) + await waitForRequests(test.adapter, 1) + + await test.driver.dispose() + + expect(test.ctx.goals.get(test.agent)).toMatchObject({ + phase: 'active', + activation: 'disarmed', + roundsStarted: 1, + }) + await test.agent.whenIdle() + expect(test.adapter.requests).toHaveLength(1) + }) + + it('cancels an accepted queued round and awaits its driver task during teardown', async () => { + const test = await harness([]) + let unloading: Promise | undefined + test.ctx.on('agent/queued', (agent, _content, info) => { + if (agent === test.agent && info.source.kind === 'goal' && unloading === undefined) { + unloading = Promise.resolve(test.driver.dispose()) + } + }) + test.ctx.goals.create(test.agent, { objective: 'unload while queued' }) + await vi.waitFor(() => { expect(unloading).toBeDefined() }) + await unloading + + expect(test.ctx.goals.get(test.agent)).toMatchObject({ + phase: 'active', + activation: 'disarmed', + roundsStarted: 1, + }) + expect(test.adapter.requests).toHaveLength(1) + }) + + it('resets process-local scheduling state at a session-start edge', async () => { + const test = await harness([textResponse('after explicit resume')]) + const created = test.ctx.goals.create(test.agent, { objective: 'restart safely', maxGoalRounds: 1 }) + agentEvents(test.ctx, test.agent).emit('agent/session-start', 'resume') + await Promise.resolve() + + expect(test.ctx.goals.get(test.agent)).toMatchObject({ activation: 'disarmed', roundsStarted: 0 }) + expect(test.adapter.requests).toHaveLength(0) + + test.ctx.goals.resume(test.agent, created) + await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'budget-limited') + expect(test.adapter.requests).toHaveLength(1) + }) + + it('ignores session events without an exact owning agent and retires disposed agent state', async () => { + const test = await harness([]) + const orphan = test.ctx.sessions.create(SessionId('goal-session-orphan')) + orphan.append('turn/start', { + turn: 1, + trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'test' } }, + }) + orphan.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + const handle = await test.ctx.agents.create({ + sessionId: SessionId('goal-session-disposed'), + agentOptions: { provider: 'mock', model: 'mock' }, + }) + await handle.dispose() + + expect(test.ctx.agents.get(handle.agent.id)).toBeUndefined() + }) +}) diff --git a/packages/goal/goal-session/tsconfig.json b/packages/goal/goal-session/tsconfig.json new file mode 100644 index 0000000000..d3cf5d5294 --- /dev/null +++ b/packages/goal/goal-session/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/agent" + }, + { + "path": "../goal" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + } + ] +} diff --git a/packages/goal/goal/README.md b/packages/goal/goal/README.md index 57f366c844..3bc324f785 100644 --- a/packages/goal/goal/README.md +++ b/packages/goal/goal/README.md @@ -15,7 +15,7 @@ Event-sourced same-session goal state. The service retains one current completio ## Service contract -`ctx.goals` accepts only the exact live `Agent` instance registered under its id. `get()` returns a detached `GoalView`; mutations use a `GoalRef { id, revision }` compare-and-set fence and reject stale refs. The service exposes create, edit, pause, resume, complete, block, usage-limit, budget-limit, and clear verbs through the generated [service catalog](../../../docs/cordis-catalog/services.md). +`ctx.goals` accepts only the exact live `Agent` instance registered under its id. `get()` returns a detached `GoalView`; mutations use a `GoalRef { id, revision }` compare-and-set fence and reject stale refs. The service exposes create, edit, pause, resume, complete, block, usage-limit, budget-limit, and clear verbs through the generated [service catalog](../../../docs/cordis-catalog/services.md). `disarm()` is the lifecycle-only exception: it removes process-local continuation authority without writing a revision or emitting a mutation. At most one goal is current. Creation produces an active revision-one goal and arms it. A non-complete goal must be edited, transitioned, or cleared; a completed goal may be replaced by a globally fresh id. Edits retain phase and activation. Pause, completion, blocking, limit transitions, and clear disarm activation. Resume accepts a stopped phase or a disarmed active goal only while the configured round cap has remaining capacity; an active armed goal rejects the redundant operation. @@ -23,7 +23,7 @@ Every non-clear mutation appends a complete versioned snapshot through `agent.in Injection may append immediately or wait in an active tool-batch FIFO. The service overlays accepted pending changes in memory and reconciles each exact payload when it enters the log, so consecutive model-tool mutations see their own latest revisions without treating an unlogged cache as durable state. `goal/changed` fires after the append or enqueue succeeds; listener failures are contained. -Activation is never persisted. A fresh cache and every `agent/session-start` edge disarm it even when replay finds an active durable phase. Session resume and fork therefore retain the objective, phase, revisions, and admitted-round count without initiating work; a later explicit resume mutation must arm continuation. +Activation is never persisted. A fresh cache and every `agent/session-start` edge disarm it even when replay finds an active durable phase. A continuation driver also calls `disarm()` before unload or after durability uncertainty. Session resume, fork, and driver replacement therefore retain the objective, phase, revisions, and admitted-round count without initiating work; a later explicit resume mutation must arm continuation. ## Extension points diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 726ceb6c53..7b24fbe824 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -139,6 +139,21 @@ export class GoalService extends Service { return this.view(cache) } + /** + * Remove process-local continuation authority without changing durable goal + * phase or revision. Lifecycle owners use this before unloading a driver; + * a later human-authorized {@link resume} records the new activation edge. + * @param agent - owning live agent. + * @returns a fresh disarmed view, or `undefined` when no goal is current. + */ + disarm(agent: Agent): GoalView | undefined { + this.assertLive(agent) + const cache = this.cache(agent.session) + this.sync(agent.session, cache) + cache.activation = 'disarmed' + return this.view(cache) + } + /** * Create and arm a goal. A completed goal may be replaced; every other * current phase must be cleared or resumed instead. diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index c8aa5bc3e3..08445a27ed 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -235,6 +235,20 @@ describe('GoalService creation and replay', () => { expect(() => foldGoal(session.events)).not.toThrow() }) + it('lets a lifecycle owner disarm without writing a durable revision', async () => { + const { ctx, agent, session } = await harness() + const goal = ctx.goals.create(agent, { objective: 'survive driver reload' }) + const before = session.events.length + expect(ctx.goals.disarm(agent)).toMatchObject({ + id: goal.id, + revision: goal.revision, + phase: 'active', + activation: 'disarmed', + }) + expect(session.events).toHaveLength(before) + expect(ctx.goals.resume(agent, goal)).toMatchObject({ revision: 2, activation: 'armed' }) + }) + it('requires the exact live registry instance for reads and mutations', async () => { const { ctx, agent } = await harness() const impostor = { ...agent, session: new Session(agent.id) } diff --git a/packages/support/invariants/src/scoped-events.generated.ts b/packages/support/invariants/src/scoped-events.generated.ts index 3c112d59a8..a007ab4174 100644 --- a/packages/support/invariants/src/scoped-events.generated.ts +++ b/packages/support/invariants/src/scoped-events.generated.ts @@ -28,6 +28,7 @@ function adapt( } const scopedSubjectResolvers = Object.freeze({ + 'agent/cancel-requested': adapt<'agent/cancel-requested'>(args => args[0]), 'agent/created': adapt<'agent/created'>(args => args[0]), 'agent/disposed': adapt<'agent/disposed'>(args => args[0]), 'agent/error': adapt<'agent/error'>(args => args[0]), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1d3bd1fe7..728fa9ac33 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,6 +125,9 @@ importers: '@deepseek-ai/dsh-goal': specifier: workspace:* version: link:../packages/goal/goal + '@deepseek-ai/dsh-goal-session': + specifier: workspace:* + version: link:../packages/goal/goal-session '@deepseek-ai/dsh-hooks-claude': specifier: workspace:* version: link:../packages/hooks/hooks-claude @@ -1010,6 +1013,39 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/goal/goal-session: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../goal + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/goal/tool-goal: dependencies: schemastery: diff --git a/tsconfig.build.json b/tsconfig.build.json index db84df1542..30e266da46 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -27,6 +27,7 @@ { "path": "./packages/core/agent" }, { "path": "./packages/goal/goal" }, { "path": "./packages/goal/tool-goal" }, + { "path": "./packages/goal/goal-session" }, { "path": "./packages/context/time-context" }, { "path": "./packages/ui/user-interaction" }, { "path": "./packages/ui/user-approval" }, diff --git a/tsconfig.json b/tsconfig.json index 27b0e1d184..d64438c4ab 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -38,6 +38,7 @@ { "path": "./packages/core/agent" }, { "path": "./packages/goal/goal" }, { "path": "./packages/goal/tool-goal" }, + { "path": "./packages/goal/goal-session" }, { "path": "./packages/context/time-context" }, { "path": "./packages/ui/user-interaction" }, { "path": "./packages/ui/user-approval" }, diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index 316af95351..e959832c12 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -2,10 +2,34 @@ # Harness events -Every event the harness packages declare on the cordis event bus (43 total), grouped by scope. The **mode** is the dispatch semantics (`emit` fire-and-forget, `parallel` awaited, `serial` first-bail, `waterfall` veto-chain — a waterfall listener MUST call `next()` to delegate). +Every event the harness packages declare on the cordis event bus (44 total), grouped by scope. The **mode** is the dispatch semantics (`emit` fire-and-forget, `parallel` awaited, `serial` first-bail, `waterfall` veto-chain — a waterfall listener MUST call `next()` to delegate). ## agent/* +### agent/cancel-requested + +**Mode:** `emit` + +```ts website-api +/** + * Effective broad cancellation was requested, before queued/steering work + * is cleared or the active step is aborted. This observe-only notification + * cannot veto cancellation; listener failures are contained. + * @param agent - the agent whose current work is being cancelled. + * @param reason - resolved cancellation reason, including the default. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ +'agent/cancel-requested'(this: Scoped, agent: Agent, reason: string): void +``` + +Effective broad cancellation was requested, before queued/steering work is cleared or the active step is aborted. This observe-only notification cannot veto cancellation; listener failures are contained. + +- `agent` — the agent whose current work is being cancelled. +- `reason` — resolved cancellation reason, including the default. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L186) + ### agent/created **Mode:** `emit` @@ -28,7 +52,7 @@ A fully configured agent and live session were published. Setup is composition-o - `agent` — the newly registered agent with its live session and completed setup. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L147) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L148) ### agent/disposed @@ -50,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef - `agent` — the exact agent removed from the registry. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L156) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L157) ### agent/error @@ -77,7 +101,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w - `step` — the step at which the failure surfaced. - `error` — the failure, verbatim. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L311) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L322) ### agent/post-step @@ -105,7 +129,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in - `step` — the open step number. - `signal` — the turn abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L264) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L275) ### agent/pre-step @@ -133,7 +157,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending - `step` — the pending step number. - `signal` — the turn abort signal. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L204) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L215) ### agent/prompt-submit @@ -158,7 +182,7 @@ Allow, rewrite, or block one drained prompt before it becomes a user message. Ca - `content` — the drained message's blocks, as queued. - `source` — the message's resolved source. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L214) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L225) ### agent/queued @@ -183,7 +207,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already - `content` — the accepted content blocks retained by the inbox. - `info` — the accepted source plus whether it entered as steering. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L175) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L176) ### agent/request @@ -211,7 +235,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha - `step` — the step whose request this is. - `config` — the config the loop would use (frozen); return a replacement to switch. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L226) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L237) ### agent/request-error @@ -243,7 +267,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens - `retryAttempt` — zero-based number of prior recovery retries. - `signal` — the turn abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L278) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L289) ### agent/session-prefix @@ -273,7 +297,7 @@ Compose request-only messages placed before derived history. The frozen result i - `prefix` — the frozen seed; return an extended replacement. - `signal` — aborts composition when the step is torn down. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L241) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L252) ### agent/session-start @@ -298,7 +322,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to - `agent` — the agent whose session lifecycle began. - `source` — why the session started (fresh startup, resume, …). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L188) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L199) ### agent/status @@ -321,7 +345,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no - `agent` — the agent whose status flipped. - `status` — the status just entered (the transition's destination). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L165) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L166) ### agent/step-result @@ -348,7 +372,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va - `step` — the step that produced the message. - `message` — the assistant message as assembled from the stream. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L252) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L263) ### agent/turn-continuation @@ -373,7 +397,7 @@ Override whether the turn continues. The default continues after tool calls or s - `turn` — the turn being continued or stopped. - `defaultDecision` — what the loop would do absent an override. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L288) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L299) ### agent/turn-stop @@ -397,7 +421,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a - `agent` — the agent whose composed continuation outcome may be stopped. - `turn` — the turn at its terminal-stop checkpoint. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L298) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L309) ## agent-loop/* diff --git a/website/zh-CN/api/harness/goals.md b/website/zh-CN/api/harness/goals.md index 146cb04193..a5a4a4471e 100644 --- a/website/zh-CN/api/harness/goals.md +++ b/website/zh-CN/api/harness/goals.md @@ -47,6 +47,27 @@ Read the current goal for one exact live agent. [Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L135) +### ctx.goals.disarm(agent) + +```ts website-api +/** + * Remove process-local continuation authority without changing durable goal + * phase or revision. Lifecycle owners use this before unloading a driver; + * a later human-authorized {@link resume} records the new activation edge. + * @param agent - owning live agent. + * @returns a fresh disarmed view, or `undefined` when no goal is current. + */ +disarm(agent: Agent): GoalView | undefined +``` + +Remove process-local continuation authority without changing durable goal phase or revision. Lifecycle owners use this before unloading a driver; a later human-authorized resume records the new activation edge. + +- `agent` — owning live agent. + +**Returns** a fresh disarmed view, or `undefined` when no goal is current. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L149) + ### ctx.goals.create(agent, request) ```ts website-api @@ -67,7 +88,7 @@ Create and arm a goal. A completed goal may be replaced; every other current pha **Returns** the created live view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L149) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L164) ### ctx.goals.edit(agent, ref, request) @@ -90,7 +111,7 @@ Edit objective and/or round cap without changing phase. **Returns** the edited view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L174) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L189) ### ctx.goals.pause(agent, ref) @@ -111,7 +132,7 @@ Pause an active goal and disarm automatic continuation. **Returns** the paused view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L195) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L210) ### ctx.goals.resume(agent, ref) @@ -133,7 +154,7 @@ Resume and arm a stopped goal, or rearm an active goal after a session-start edg **Returns** the active view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L206) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L221) ### ctx.goals.complete(agent, ref) @@ -154,7 +175,7 @@ Mark a current non-complete goal complete and disarm it. **Returns** the completed view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L231) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L246) ### ctx.goals.block(agent, ref) @@ -175,7 +196,7 @@ Mark an active goal blocked and disarm it. **Returns** the blocked view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L248) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L263) ### ctx.goals.markUsageLimited(agent, ref) @@ -196,7 +217,7 @@ Mark an active goal stopped by an external usage limit. **Returns** the usage-limited view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L258) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L273) ### ctx.goals.markBudgetLimited(agent, ref) @@ -217,7 +238,7 @@ Mark an active goal stopped at its configured round cap. **Returns** the budget-limited view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L268) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L283) ### ctx.goals.clear(agent, ref) @@ -238,4 +259,4 @@ Clear the current goal while retaining a durable tombstone and history. **Returns** the tombstone ref whose revision is one past the cleared snapshot. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L295) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L310) From 2850c22b7a075ce77d8bb07d80cdeb5cd1917118 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:11:59 +0800 Subject: [PATCH 04/25] feat(ui): add plugin command registry --- docs/architecture.md | 23 +- docs/capability-seams.md | 7 + docs/config-catalog.md | 13 +- docs/cordis-catalog/events.md | 17 + docs/cordis-catalog/services.md | 44 +++ docs/core-data-structures/commands.md | 95 ++++++ docs/core-data-structures/core.md | 1 + docs/event-producer-consumer.md | 1 + docs/glossary.md | 6 + docs/module-graph.md | 16 +- docs/rfc/INDEX.md | 1 + ...7-19-plugin-command-registration.i18n.yaml | 6 + .../2026-07-19-plugin-command-registration.md | 80 +++++ ...26-07-19-plugin-command-registration.zh.md | 80 +++++ .../advanced-toolchain/stdout.golden.jsonl | 1 + .../snapshots/bash-spill/stdout.golden.jsonl | 1 + .../both-mode-turn/stdout.golden.jsonl | 1 + .../cancel-tool-calls/stdout.golden.jsonl | 1 + .../snapshots/cancel/stdout.golden.jsonl | 1 + .../code-mode-turn/stdout.golden.jsonl | 1 + .../stdout.golden.jsonl | 1 + .../config-options/stdout.golden.jsonl | 1 + .../cordis-inspect-jsdoc/stdout.golden.jsonl | 1 + .../error-finish/stdout.golden.jsonl | 1 + .../escalation-approved/stdout.golden.jsonl | 1 + .../escalation-rejected/stdout.golden.jsonl | 1 + .../snapshots/fs-edit/stdout.golden.jsonl | 1 + .../fs-policy-reject/stdout.golden.jsonl | 1 + .../fs-read-window/stdout.golden.jsonl | 1 + .../snapshots/fs-read/stdout.golden.jsonl | 1 + .../fs-terminal-card/stdout.golden.jsonl | 1 + .../fs-write-overwrite/stdout.golden.jsonl | 1 + .../snapshots/fs-write/stdout.golden.jsonl | 1 + .../snapshots/handshake/stdout.golden.jsonl | 1 + .../stdout.golden.jsonl | 1 + .../stdout.golden.jsonl | 1 + .../hook-cc-pretool-ask/stdout.golden.jsonl | 1 + .../hook-cc-pretool-deny/stdout.golden.jsonl | 1 + .../stdout.golden.jsonl | 1 + .../stdout.golden.jsonl | 1 + .../hook-cc-stop-continue/stdout.golden.jsonl | 1 + .../stdout.golden.jsonl | 1 + .../stdout.golden.jsonl | 1 + .../stdout.golden.jsonl | 1 + .../stdout.golden.jsonl | 1 + .../stdout.golden.jsonl | 1 + .../stdout.golden.jsonl | 1 + .../model-switching/stdout.golden.jsonl | 1 + .../snapshots/multi-turn/stdout.golden.jsonl | 1 + .../parallel-tool-calls/stdout.golden.jsonl | 1 + .../permission-switching/stdout.golden.jsonl | 1 + .../repeat-tool-guard/stdout.golden.jsonl | 1 + .../snapshots/skill-load/stdout.golden.jsonl | 1 + .../subagent-fork/stdout.golden.jsonl | 1 + .../subagent-mixed/stdout.golden.jsonl | 1 + .../subagent-multi/stdout.golden.jsonl | 1 + .../subagent-spawn/stdout.golden.jsonl | 1 + .../snapshots/text-turn/stdout.golden.jsonl | 1 + .../snapshots/todo-plan/stdout.golden.jsonl | 1 + .../tool-call-turn/stdout.golden.jsonl | 1 + .../workflow-run/stdout.golden.jsonl | 1 + .../workspace-context/stdout.golden.jsonl | 1 + .../workspace-edit/stdout.golden.jsonl | 1 + examples/tui-agent/tests/tui.snapshot.ts | 2 + knip.json | 4 + .../cordis/tool-cordis/src/api-catalog.ts | 53 +++ packages/examples/README.md | 4 +- packages/examples/acp-demo/README.md | 1 + packages/examples/acp-demo/package.json | 4 +- packages/examples/acp-demo/src/index.ts | 6 +- packages/examples/acp-demo/tsconfig.json | 3 + packages/examples/stdio-demo/README.md | 3 +- packages/examples/stdio-demo/package.json | 4 +- packages/examples/stdio-demo/src/index.ts | 10 +- packages/examples/stdio-demo/tsconfig.json | 3 + packages/sdk/helper/README.md | 2 +- .../sdk/helper/src/features/builtin/app.ts | 4 + packages/sdk/helper/tests/project.spec.ts | 1 + packages/ui/README.md | 3 +- packages/ui/acp/README.md | 23 +- packages/ui/acp/acp-feature-support.md | 17 +- packages/ui/acp/package.json | 2 + packages/ui/acp/src/index.ts | 101 +++++- packages/ui/acp/tests/commands.spec.ts | 258 ++++++++++++++ packages/ui/acp/tests/harness.ts | 2 + packages/ui/acp/tsconfig.json | 3 + packages/ui/commands/README.md | 31 ++ packages/ui/commands/package.json | 35 ++ packages/ui/commands/src/index.ts | 319 ++++++++++++++++++ packages/ui/commands/tests/commands.spec.ts | 262 ++++++++++++++ packages/ui/commands/tsconfig.json | 24 ++ packages/ui/tui/README.md | 8 +- packages/ui/tui/package.json | 2 + packages/ui/tui/src/index.ts | 142 +++++--- packages/ui/tui/tests/harness.ts | 2 + packages/ui/tui/tests/plugin-shape.spec.ts | 2 +- .../snapshots/disposed-terminal.golden.txt | 43 ++- .../snapshots/errors-and-help.golden.txt | 43 ++- packages/ui/tui/tests/tui.spec.ts | 87 +++++ packages/ui/tui/tsconfig.json | 3 + pnpm-lock.yaml | 30 ++ python/sdk-runtime/package.json | 1 + scripts/gen-cordis-catalog.ts | 4 + scripts/gen-doc-graphs.ts | 8 + scripts/type-equiv.manifest.json | 8 + tsconfig.build.json | 1 + tsconfig.json | 1 + website/.vitepress/config/api-sidebar.json | 4 + website/zh-CN/api/harness/commands.md | 97 ++++++ website/zh-CN/api/harness/events.md | 21 +- 110 files changed, 2000 insertions(+), 130 deletions(-) create mode 100644 docs/core-data-structures/commands.md create mode 100644 docs/rfc/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-07-19-plugin-command-registration.md create mode 100644 docs/rfc/implemented/feature/2026-07-19-plugin-command-registration.zh.md create mode 100644 packages/ui/acp/tests/commands.spec.ts create mode 100644 packages/ui/commands/README.md create mode 100644 packages/ui/commands/package.json create mode 100644 packages/ui/commands/src/index.ts create mode 100644 packages/ui/commands/tests/commands.spec.ts create mode 100644 packages/ui/commands/tsconfig.json create mode 100644 website/zh-CN/api/harness/commands.md diff --git a/docs/architecture.md b/docs/architecture.md index d0cc86895a..b83c7494bb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -149,35 +149,36 @@ Streaming uses raw chunks (`block-start` through `finish`) and `BlockAssembler`. ### Capability Pattern -A swappable capability usually splits into **interface / implementation / consumer**: the interface owns its `ctx` key and events, an implementation registers a backend, and a consumer exposes model behavior through tools or prompts. Bash is the reference; the [capability graph](capability-seams.md) shows every family. +A swappable capability usually splits into **interface / implementation / consumer**: service/events, a backend, and model-facing tools/prompts. Bash is the reference; the [capability graph](capability-seams.md) maps each family. -Some seams bend the template deliberately: LLM combines interface and consumer because adapters implement it; filesystem wraps provider primitives with policy; web keeps search/fetch provider registries behind one service; skills and subagents use named providers. Subagents spawn fresh, fork a completed-turn prefix, or use ACP children ([subagent.md](core-data-structures/subagent.md)). +Exceptions combine layers: LLM interface/consumer; filesystem policy; web registries; named skill/subagent providers. Subagents spawn fresh, fork a completed-turn prefix, or use ACP children ([subagent.md](core-data-structures/subagent.md)). -`dsh-workspace-context` composes baselines on `agent/session-prefix` and appends `ctx.fs`-discovered nested changes on `tools/post-execute`; its [RFC](rfc/implemented/feature/2026-06-24-workspace-context.md) records isolation. `dsh-paths` owns shared paths. +`dsh-workspace-context` composes `agent/session-prefix` baselines and `ctx.fs` changes; its [RFC](rfc/implemented/feature/2026-06-24-workspace-context.md) records isolation. `dsh-paths` owns shared paths. ### Bundles And Apps -`dsh-agent-spine-demo` bundles the default spine ([README](../packages/examples/agent-spine-demo/README.md)). `dsh-stdio-demo` selects `dsh-tui` for interactive terminals and line-oriented `dsh-stdio` for pipes; `dsh-cli-demo` runs one persisted headless turn with format-pure stdout; `dsh-acp-demo` adds stdout-pure ACP over JSON-RPC ([ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`; the Python SDK supplies its default only without an explicit config channel and drives `dsh-jsonrpc` over line-delimited JSON-RPC ([Python SDK](../python/README.md)). Deployments remain thin leaves with swappable backends and optional product tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). +`dsh-agent-spine-demo` bundles the default spine ([README](../packages/examples/agent-spine-demo/README.md)). Apps add front doors; TUI/ACP mount commands ([ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`, including the Python SDK default ([Python SDK](../python/README.md)). Deployments stay thin with swappable backends/tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). ### Where New Behavior Goes -New behavior should attach to a documented extension point; changing the shipped loop requires updating this map. +New behavior attaches to a documented extension point; a loop change updates this map. | Goal | Mechanism | |---|---| | Add a model provider | register an adapter on `ctx.llm` | -| Add a model-facing capability | register a tool on `ctx.tools`; schemas flow into prompt assembly | -| Add command execution | implement and register a `ctx.bash` backend | -| Add a long-running/background capability | register the work on `ctx.tasks`; the generic `task_*` tools collect/stop it | +| Add a model-facing capability | register on `ctx.tools`; schemas enter prompt assembly | +| Add shell execution | implement and register a `ctx.bash` backend | +| Add a human command | register on `ctx.commands`; adapters discover and dispatch it without a model turn | +| Add background work | register on `ctx.tasks`; generic `task_*` tools collect or stop it | | Add filesystem access or policy | implement a `ctx.fs` provider or listen on `fs/*` policy events | | Confine spawned processes | a `ctx.sandbox` backend; consumers wrap their argv before spawning | -| Intercept prompts, requests, model completion/failure, tool use, or continuation | listen on the relevant `agent/*` or `tools/*` event; use serial `agent/turn-stop` for a monotonic terminal stop | -| Add a session-stable request prefix outside history | compose it on `agent/session-prefix`, once per loop instance; logged on the request header | +| Intercept a request, tool, or turn | use its `agent/*` or `tools/*` event; `agent/turn-stop` is the serial terminal stop | +| Add a session-stable prefix outside history | compose `agent/session-prefix`; the request header logs it | | Add UI or editor integration | drive `ctx.agents` and render from `session/event` | | Add durable session state | add a `SessionEventMap` member and render/replay from the log | | Manage a same-session objective | call `ctx.goals`; drive continuation through `Agent` and `agent/*` seams | | Fork a live session | use `ctx.sessions.fork(source, boundary?, childSessionId?)` | -| Scope a tool, prompt section, or listener to ONE agent | register it through that agent's `agent.ctx` (see Agent Scope) | +| Scope a registration to one agent | use that agent's `agent.ctx` (see Agent Scope) | The [extension cookbook](cookbook/extension-cookbook.md) carries plugin skeletons and the feature-to-seam map; step-by-step guides cover [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [vendored packages](cookbook/adding-a-vendored-package.md). diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 64ddcc40c5..50cb528951 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -46,6 +46,9 @@ flowchart LR pkg_user_interaction["user-interaction"] svc_userInteraction["ctx.userInteraction
Human question/answer seam"] pkg_stdio_demo["stdio-demo"] + pkg_commands["commands"] + svc_commands["ctx.commands
Human command registry"] + pkg_tui["tui"] pkg_skill["skill"] svc_skills["ctx.skills
Skill provider registry"] pkg_skill_local["skill-local"] @@ -107,6 +110,7 @@ flowchart LR pkg_bash_sandbox --> svc_bash pkg_code_runtime --> svc_codeRuntime pkg_code_runtime_worker --> svc_codeRuntime + pkg_commands --> svc_commands pkg_compact --> svc_compact pkg_compact_basic --> svc_compact pkg_fs --> svc_fs @@ -159,6 +163,8 @@ flowchart LR svc_bash --> pkg_hooks_codex svc_bash --> pkg_tool_bash svc_codeRuntime --> pkg_tools + svc_commands --> pkg_acp + svc_commands --> pkg_tui svc_compact --> pkg_compact_basic svc_fs --> pkg_tool_fs svc_llm --> pkg_agent_loop @@ -217,6 +223,7 @@ flowchart LR | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | | `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`stdio-demo`](../packages/examples/stdio-demo), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`stdio-demo`](../packages/examples/stdio-demo), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | +| `ctx.commands` | `core` | [`commands`](../packages/ui/commands) | - | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | - | Plugins register direct human commands; TUI and ACP resolve each agent and surface without sending the invocation to the model. | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-demo`](../packages/examples/stdio-demo), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7b3a700742..2b540f61a8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -11,7 +11,7 @@ A `Requires:` line lists the service keys the plugin `inject`s: its `cordis.yml` ## `@deepseek-ai/dsh-acp` -Requires: `agents` · `sessionPersistence` · `tools` · `userInteraction` · `llm` · `systemPrompt` +Requires: `agents` · `commands` · `sessionPersistence` · `tools` · `userInteraction` · `llm` · `systemPrompt` ```ts config-catalog /** Plugin config: the agent template ACP sessions are created from. */ @@ -27,7 +27,7 @@ export interface AcpConfig { Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/ui/acp/src/index.ts:206`](../packages/ui/acp/src/index.ts) +Source: [`packages/ui/acp/src/index.ts:217`](../packages/ui/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-demo` @@ -71,7 +71,7 @@ export interface Config { Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) -Source: [`packages/examples/acp-demo/src/index.ts:33`](../packages/examples/acp-demo/src/index.ts) +Source: [`packages/examples/acp-demo/src/index.ts:34`](../packages/examples/acp-demo/src/index.ts) ## `@deepseek-ai/dsh-agent-loop` @@ -896,7 +896,7 @@ export type TerminalMode = 'auto' | 'readline' | 'tui' Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts) -Source: [`packages/examples/stdio-demo/src/index.ts:75`](../packages/examples/stdio-demo/src/index.ts) +Source: [`packages/examples/stdio-demo/src/index.ts:76`](../packages/examples/stdio-demo/src/index.ts) ## `@deepseek-ai/dsh-subagent-acp` @@ -1256,7 +1256,7 @@ Source: [`packages/core/tools/src/index.ts:382`](../packages/core/tools/src/inde ## `@deepseek-ai/dsh-tui` -Requires: `agents` · `userInteraction` · `tools` +Requires: `agents` · `commands` · `userInteraction` · `tools` ```ts config-catalog /** Serializable plugin configuration. */ @@ -1288,7 +1288,7 @@ export interface TuiConfig { } ``` -Source: [`packages/ui/tui/src/index.ts:100`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:101`](../packages/ui/tui/src/index.ts) ## `@deepseek-ai/dsh-user-approval` @@ -1485,6 +1485,7 @@ Source: [`packages/context/workspace-context/src/config.ts:16`](../packages/cont These load from a `cordis.yml` entry with no `config:` block; they declare no config surface. - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) +- `@deepseek-ai/dsh-commands` ([`packages/ui/commands/src/index.ts`](../packages/ui/commands/src/index.ts)) - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts)) - `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts)) - `@deepseek-ai/dsh-invariants` — requires `sessions` ([`packages/support/invariants/src/index.ts`](../packages/support/invariants/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 2ab18f89b8..64d83565b6 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -410,6 +410,23 @@ Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalReques Source: [`packages/ui/user-approval/src/index.ts:31`](../../packages/ui/user-approval/src/index.ts) +## `commands/*` + +### `commands/change` — emit + +A command was registered or unregistered. This is an unfiltered registry notification because a global or scoped change may affect any UI view. + +```ts cordis-catalog +/** + * A command was registered or unregistered. This is an unfiltered registry + * notification because a global or scoped change may affect any UI view. + * @mode emit + */ +'commands/change'(): void +``` + +Source: [`packages/ui/commands/src/index.ts:93`](../../packages/ui/commands/src/index.ts) + ## `fs/*` ### `fs/edit-intent` — waterfall diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index de04e18628..c098100dbe 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -340,6 +340,50 @@ Types: [CodeRunRequest](../core-data-structures/code-runtime.md) · [CodeRunResu Source: [`packages/code-runtime/code-runtime/src/index.ts:30`](../../packages/code-runtime/code-runtime/src/index.ts) +## `ctx.commands` — `CommandService` + +Human-command registry. Plain-context definitions are global; definitions registered through a command-injected child of an agent context shadow globals for that agent. + +```ts cordis-catalog +/** + * Register a global or calling-agent-scoped command. + * @param definition - discovery metadata, surface mask, and direct UI handler. + * @returns the exact effect disposer that unregisters this definition. + */ +register(definition: CommandDefinition): () => void + +/** + * List the effective immutable command descriptors for one agent and surface. + * @param agent - exact receiving agent and scoped-layer key. + * @param surface - UI adapter requesting discovery metadata. + * @returns name-sorted descriptors after scoped shadowing and surface filtering. + */ +list(agent: Agent, surface: CommandSurface): readonly CommandDescriptor[] + +/** + * Resolve one effective command definition. + * @param agent - exact receiving agent and scoped-layer key. + * @param surface - UI adapter performing the lookup. + * @param name - command name without a slash. + * @returns the scoped shadow or global definition when visible on the surface. + */ +find(agent: Agent, surface: CommandSurface, name: string): CommandDefinition | undefined + +/** + * Parse and execute a known command without sending it to the model. + * @param agent - exact receiving agent. + * @param surface - dispatching UI adapter. + * @param line - complete slash-command line. + * @param signal - cancellation signal owned by the UI request. + * @returns a detached result, or `undefined` when syntax/name/surface does not resolve. + */ +async execute( agent: Agent, surface: CommandSurface, line: string, signal: AbortSignal, ): Promise +``` + +Types: [Agent](../core-data-structures/core.md) · [CommandDefinition](../core-data-structures/commands.md) · [CommandDescriptor](../core-data-structures/commands.md) · [CommandResult](../core-data-structures/commands.md) · [CommandSurface](../core-data-structures/commands.md) + +Source: [`packages/ui/commands/src/index.ts:216`](../../packages/ui/commands/src/index.ts) + ## `ctx.compact` — `CompactService` (abstract seam) Abstract compaction service. Implementations own trigger policy, retention, and summarization, and may consume a separate measurement service. A successful run replaces the selected surface span with one summary node and prevents concurrent compaction of the same session. Load one implementation per context as `ctx.compact`. diff --git a/docs/core-data-structures/commands.md b/docs/core-data-structures/commands.md new file mode 100644 index 0000000000..6f2103a4b4 --- /dev/null +++ b/docs/core-data-structures/commands.md @@ -0,0 +1,95 @@ +# Human Commands + +The human-command seam of [`dsh-commands`](../../packages/ui/commands). TUI and ACP adapters use it to discover and directly execute plugin-owned commands for an exact agent without creating a model message. The [command RFC](../rfc/implemented/feature/2026-07-19-plugin-command-registration.md) owns dispatch and lifecycle rationale; the [package README](../../packages/ui/commands/README.md) owns composition and limitations. + +Source: [`packages/ui/commands/src/index.ts`](../../packages/ui/commands/src/index.ts) + +## Surface and input metadata + +A definition selects one or more adapter identities. The shipped identities are `tui` and `acp`; the string intersection keeps the registry extensible without widening editor autocomplete to plain `string`. ACP currently exposes one unstructured-input hint. + +```ts type-equiv +/** A UI adapter capable of listing and executing human commands. */ +type CommandSurface = 'tui' | 'acp' | (string & {}) +``` + +```ts type-equiv +/** Immutable command input metadata compatible with ACP unstructured input. */ +interface CommandInputDescriptor { + /** Placeholder shown before the user supplies free-form input. */ + readonly hint: string +} +``` + +## Definition + +`CommandDefinition` is the plugin-authored registration. Omitted surfaces resolve to both shipped adapters; the registry validates and freezes a detached effective definition. + +```ts type-equiv +/** Plugin-owned command registration. */ +interface CommandDefinition { + /** Lowercase command name without the leading slash. */ + readonly name: string + /** Human-readable summary used in discovery UI. */ + readonly description: string + /** Optional free-form input hint advertised to capable clients. */ + readonly input?: CommandInputDescriptor + /** Surfaces exposing this command; omission means both shipped surfaces. */ + readonly surfaces?: readonly CommandSurface[] + /** Execute against the receiving agent without sending the command to the model. */ + readonly handler: (invocation: CommandInvocation) => CommandResult | Promise +} +``` + +## Invocation and result + +The adapter owns cancellation and passes the exact target agent. `rawInput` begins immediately after the parsed name and retains the adapter-delivered separator and suffix. Results are direct UI outcomes, not tool results or session events. + +```ts type-equiv +/** Invocation passed to one registered command handler. */ +interface CommandInvocation { + /** Exact agent whose human-facing surface received the command. */ + readonly agent: Agent + /** UI adapter that dispatched the command. */ + readonly surface: CommandSurface + /** Exact text following the registered command name, including separator whitespace. */ + readonly rawInput: string + /** Cancellation signal owned by the dispatching UI request. */ + readonly signal: AbortSignal +} +``` + +```ts type-equiv +/** Expected command outcome rendered directly by the dispatching UI. */ +type CommandResult = + | { readonly kind: 'success'; readonly text?: string } + | { readonly kind: 'error'; readonly text: string } +``` + +## Discovery and parsing views + +Adapters receive handler-free immutable descriptors after scope resolution and surface filtering. `parseCommand()` returns `ParsedCommand` before registry resolution; syntax-valid input can still name an unavailable command. + +```ts type-equiv +/** Handler-free immutable command view returned to UI adapters. */ +interface CommandDescriptor { + /** Lowercase command name without the leading slash. */ + readonly name: string + /** Human-readable summary used in discovery UI. */ + readonly description: string + /** Optional free-form input hint advertised to capable clients. */ + readonly input?: CommandInputDescriptor + /** Surfaces on which this definition is visible. */ + readonly surfaces: readonly CommandSurface[] +} +``` + +```ts type-equiv +/** Syntactically valid slash command before registry resolution. */ +interface ParsedCommand { + /** Lowercase command name without the leading slash. */ + readonly name: string + /** Exact text following the command name. */ + readonly rawInput: string +} +``` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 76d94cec79..b89e9fa891 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -19,6 +19,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [token-meter.md](token-meter.md) | immutable scalar and positional replay measurements with consumed-log revisions | | [scope.md](scope.md) | scoped registration identity, dispatch carriers, and the owned `Scope` context | | [goal.md](goal.md) | persisted goal identity, lifecycle snapshots, activation, change records, and round attribution | +| [commands.md](commands.md) | the human-command seam: definitions, surface discovery, direct invocation, results, and parsing views | | [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, the turn-enclosure invariant | | [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` | | [session-query.md](session-query.md) | logical records, bounded exact-event reads, and relationship traces | diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 96bac92e01..b0de06615e 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -25,6 +25,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:299`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:309`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | +| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:93`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`emit`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:53`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/glossary.md b/docs/glossary.md index 74dc5e14d5..7759f45542 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -22,6 +22,12 @@ FIXME(glossary-completeness): Expand this glossary before the first release so i - **goal round** — one continuation cycle admitted for the current goal. The same-session driver materializes a goal round as one goal-sourced [turn](#turn), which can contain multiple steps; unrelated human turns in the same session do not consume the goal-round cap. - **goal activation** — process-local permission for a continuation consumer to admit another goal round. Activation is either `armed` or `disarmed`; it is deliberately absent from durable replay, so resume and fork require a later explicit resume mutation before automatic work. +## human command + +- **human command** — a slash-prefixed instruction interpreted and executed by a human-facing adapter through `ctx.commands`, without becoming a model message. It is distinct from a model-facing tool and from shell command execution through `ctx.bash`. +- **command plane** — discovery, parsing, dispatch, cancellation, and result rendering owned by UI adapters and command plugins. Command output is UI state unless the handler separately mutates a durable domain. +- **command surface** — the adapter identity used to filter definitions, such as `tui` or `acp`; one scoped definition may shadow a same-named global command for its exact agent. + ## loop hierarchy - **turn** — one drain of admitted input in a session, ending after the model and its tools stop or a terminal policy intervenes. diff --git a/docs/module-graph.md b/docs/module-graph.md index 71f69f0594..ffdcc066af 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -109,6 +109,7 @@ flowchart TD subgraph group_ui["packages/ui"] pkg_acp["acp"] pkg_app_boot["app-boot"] + pkg_commands["commands"] pkg_jsonrpc["jsonrpc"] pkg_permission["permission"] pkg_stdio["stdio"] @@ -225,6 +226,8 @@ flowchart TD pkg_invariants --> pkg_llm pkg_invariants --> pkg_scope pkg_invariants --> pkg_session + pkg_commands --> pkg_agent + pkg_commands --> pkg_scope pkg_user_approval --> pkg_agent pkg_user_approval --> pkg_brand pkg_user_approval --> pkg_llm @@ -335,6 +338,7 @@ flowchart TD pkg_agent_loop_testkit --> pkg_tools pkg_acp --> pkg_agent pkg_acp --> pkg_bash + pkg_acp --> pkg_commands pkg_acp --> pkg_llm pkg_acp --> pkg_permission pkg_acp --> pkg_sandbox @@ -402,6 +406,7 @@ flowchart TD pkg_stdio --> pkg_user_interaction pkg_tui --> pkg_agent pkg_tui --> pkg_agent_loop + pkg_tui --> pkg_commands pkg_tui --> pkg_llm pkg_tui --> pkg_session pkg_tui --> pkg_tools @@ -437,6 +442,7 @@ flowchart TD pkg_acp_demo --> pkg_acp pkg_acp_demo --> pkg_agent_spine_demo pkg_acp_demo --> pkg_app_boot + pkg_acp_demo --> pkg_commands pkg_acp_demo --> pkg_session_persistence_jsonl pkg_acp_demo --> pkg_tools pkg_acp_demo --> pkg_user_interaction @@ -453,6 +459,7 @@ flowchart TD pkg_stdio_demo --> pkg_agent_loop pkg_stdio_demo --> pkg_agent_spine_demo pkg_stdio_demo --> pkg_app_boot + pkg_stdio_demo --> pkg_commands pkg_stdio_demo --> pkg_llm pkg_stdio_demo --> pkg_session pkg_stdio_demo --> pkg_session_persistence_jsonl @@ -514,6 +521,7 @@ flowchart TD | [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`invariants`](../packages/support/invariants) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | +| [`commands`](../packages/ui/commands) | `ui` | [`agent`](../packages/core/agent), [`scope`](../packages/core/scope) | | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent) | @@ -537,7 +545,7 @@ flowchart TD | [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`permission`](../packages/ui/permission), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) | +| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`llm`](../packages/llm/llm), [`permission`](../packages/ui/permission), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | @@ -550,11 +558,11 @@ flowchart TD | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`stdio`](../packages/ui/stdio) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-interaction`](../packages/ui/user-interaction) | -| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | -| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | +| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`commands`](../packages/ui/commands), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`stdio-demo`](../packages/examples/stdio-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`stdio`](../packages/ui/stdio), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | +| [`stdio-demo`](../packages/examples/stdio-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`commands`](../packages/ui/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`stdio`](../packages/ui/stdio), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 72e60ffca7..ebad36fc1b 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -91,6 +91,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Dedicated full-screen TUI front door](implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) | 2026-07-17 | | [Model-facing same-session goal tools](implemented/feature/2026-07-19-model-facing-goal-tools.md) | 2026-07-19 | | [Persisted same-session goal domain](implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) | 2026-07-19 | +| [Plugin-owned human command registration](implemented/feature/2026-07-19-plugin-command-registration.md) | 2026-07-19 | | [Same-session goal-round driver](implemented/feature/2026-07-19-same-session-goal-round-driver.md) | 2026-07-19 | ### Simplification diff --git a/docs/rfc/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml b/docs/rfc/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml new file mode 100644 index 0000000000..5db909187f --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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: +# pnpm run verify-translation-pairing --write +2026-07-19-plugin-command-registration.md: 6065644f7c7948799191e7aea276c4f2db16c9fe +2026-07-19-plugin-command-registration.zh.md: 9633b20aef70e1109952f39ebb76e78fa46169ff diff --git a/docs/rfc/implemented/feature/2026-07-19-plugin-command-registration.md b/docs/rfc/implemented/feature/2026-07-19-plugin-command-registration.md new file mode 100644 index 0000000000..6065644f7c --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-plugin-command-registration.md @@ -0,0 +1,80 @@ +# RFC: Plugin-owned human command registration + +Status: implemented + +English | [中文](2026-07-19-plugin-command-registration.zh.md) + +## Problem + +The TUI owns seven slash commands, while ACP defines a standard command catalog and invocation shape. Keeping command names, help text, autocomplete, dispatch, and cancellation inside each adapter makes every new command an adapter edit, prevents optional plugins from contributing commands, and lets the two front doors drift. Treating slash input as an ordinary model prompt is also unsafe: a user-visible direct action can unexpectedly consume tokens or let the model reinterpret an unknown command. + +A shared mechanism must remain a UI concern rather than a model tool or agent-loop branch. It also needs exact per-agent visibility, HMR-safe removal, per-session ACP discovery, direct result rendering, and request-scoped cancellation without adding command text or output to model history. + +## Decision + +`@deepseek-ai/dsh-commands` in `packages/ui/commands/` is the product command registry. The terminal and ACP app bundles mount it beside their consuming front door, and the SDK project helper emits the same service when scaffolding ACP directly; the executor-less, UI-less agent spine remains independent. TUI and ACP inject the service, while command producers depend only on the registry and any domain they operate. + +### Registry contract + +A `CommandDefinition` contains a lowercase name without `/`, a non-empty description, an optional unstructured-input hint, an optional non-empty surface list, and an abortable handler. Omitted surfaces resolve to `tui` plus `acp`. Registration validates and detaches the metadata, freezes the effective definition, and returns the exact Cordis effect disposer. Duplicate names fail within one layer. + +`list(agent, surface)` returns immutable name-sorted descriptors after surface filtering and scoped shadowing. `find(agent, surface, name)` resolves the effective definition. `execute(agent, surface, line, signal)` parses and runs a visible definition, returning a detached `success` or `error` result; invalid syntax, unknown names, and hidden definitions return `undefined` so the adapter owns its direct error text. + +`parseCommand(line)` requires `/` at byte zero, a lowercase ASCII name containing letters, digits, `_`, or `-`, then whitespace or end-of-input. It preserves the complete adapter-delivered suffix as `rawInput`, including separator whitespace. Command-specific plugins own every further grammar decision. + +### Scope and lifecycle + +An unscoped registration is global. A command-injected plugin mounted beneath an agent context inherits that agent's scope key and lifetime, so its definition shadows a same-named global only for that exact agent. The child declares its own `commands` injection because `agent.ctx` intentionally inherits the core agent-loop dependency surface; adding a UI service to the loop merely to enable scoped registration would invert the dependency graph. + +Registration and removal emit the unfiltered `commands/change` registry notification. Adapters recompute each live agent's effective view rather than trying to infer which sessions a change affects. Cordis ownership removes definitions when their producer, UI instance, or agent scope unloads, so HMR cannot leave stale discovery entries or handlers. + +### Direct dispatch and cancellation + +Commands run in a human-only command plane. Their input does not become `user/message`, their output does not become a session event, and neither is sent to the model. A handler receives the exact target agent, surface, raw input, and request-owned `AbortSignal`. The registry stops awaiting an uncooperative handler when the signal aborts; the handler remains responsible for stopping external side effects already started. + +Expected handler failures return `CommandResult.error`. Thrown or malformed results remain adapter-visible command failures, not model messages. This boundary deliberately separates UI output from durable domain mutation: a goal command may change `ctx.goals`, for example, but the goal service owns that persisted state. + +### TUI mapping + +The TUI registers `help`, `clear`, `cancel`, `reasoning`, `tools`, `redraw`, and `exit` as agent-scoped command definitions instead of switching on strings. Its autocomplete and help view read the live `tui` catalog, so plugin commands appear and disappear with their effects. Any submitted line beginning with `/` stays in the command plane; unknown input produces a terminal warning rather than falling through to `Agent.send()` or `Agent.steer()`. + +Each submitted command owns an `AbortController`. TUI disposal aborts outstanding dispatches, removes the local definitions, and waits for the command-producing fiber before completing teardown. + +### ACP mapping + +The bridge follows the current [ACP v1 slash-command contract](https://agentclientprotocol.com/protocol/v1/slash-commands). `session/new` and `session/load` emit the exact agent's full `available_commands_update` snapshot; every registry change emits a replacement snapshot for each live session. Names, descriptions, and optional unstructured-input hints map directly to `AvailableCommand`. + +ACP permits a command prompt to contain additional supported content blocks. The bridge applies its ordinary lossless `text` and `resource_link` flattening, then enters the command plane when the result starts with `/`. Unsupported prompt blocks are rejected by the existing capability boundary. Known commands execute directly; unknown or malformed slash input returns a direct error and never reaches the model. Successful text, expected errors, and thrown-failure diagnostics stream as live `agent_message_chunk` output and settle `end_turn`. + +One model prompt or direct command may be in flight per ACP session, independently across sessions. `session/cancel` aborts the direct command when one owns the request; it calls `Agent.cancel()` only for an agent prompt, so cancelling a command cannot destroy unrelated queued or injected agent work. Connection teardown aborts commands and then disposes the owned agents. + +## Testing + +The registry suite covers syntax boundaries, immutable normalization, default and explicit surfaces, deterministic sorting, global and scoped shadowing, duplicate rejection, exact disposal, change-notification rollback, direct invocation, expected and malformed results, synchronous and asynchronous failure, and every abort timing edge at per-file 100% statement, branch, function, and line coverage. + +TUI tests exercise all migrated built-ins, live plugin discovery, help/autocomplete refresh, direct results, unknown-command rejection, raw-input delivery, definition removal, startup rollback, and disposal cancellation. ACP tests use the real SDK connection, agent factory, loop, and JSONL persistence to verify create/load snapshots, dynamic updates, scoped multi-session catalogs, supported-block flattening, direct success/error/failure, unknown-command isolation, cancellation, and the absence of model requests or session messages. The SDK helper suite pins direct-ACP composition. Keyless ACP and terminal snapshots pin the new protocol and rendered transcript shapes. + +## Alternatives considered + +- **Keep adapter-local switches** — rejected because optional plugins cannot contribute discovery and behavior without editing every front door. +- **Represent human commands as model tools** — rejected because discovery and direct invocation are human UI behavior; routing through the model adds latency, token cost, and reinterpretation. +- **Put the registry in the core agent spine** — rejected because headless and JSON-RPC agents do not consume it, while the two UI app bundles can compose it explicitly. +- **Make `dsh-agent-loop` inject commands** — rejected because the loop does not execute or discover human commands. Agent-scoped producers declare the UI dependency in a child plugin instead. +- **Send unknown slash input to the model** — rejected because typoed or unavailable direct actions must fail predictably rather than change execution planes. +- **Persist generic command input and output** — rejected because adapter notices are not model-visible state. A handler that changes durable behavior calls the owning domain API, which records its own events. +- **Restrict ACP commands to one text block** — rejected because ACP v1 permits accompanying content; the bridge already has a lossless accepted-block translation. + +## Consequences + +- Command producers are ordinary removable plugins, and TUI/ACP share one validated catalog and dispatch contract. +- Agent-specific definitions retain existing flat scope and shadow semantics without a core-to-UI dependency. +- Unknown slash input and command output are deterministic UI behavior with zero direct model tokens. +- ACP clients receive current per-session snapshots after creation, load, registration, and HMR removal. +- Direct command cancellation is isolated from model-turn cancellation. + +## Known limitations and deferred work + +- Input metadata is ACP's current unstructured text hint. Typed forms, argument schemas, and completion providers remain command-owned or require a later protocol extension. +- Generic command output is live-only and is not reconstructed after TUI restart or ACP reconnect. +- Registry cancellation stops awaiting immediately, but external work stops only when a handler cooperates with its signal. +- The shipped line-oriented `dsh-stdio` and JSON-RPC SDK front doors do not expose the command plane; only TUI and ACP consume it. diff --git a/docs/rfc/implemented/feature/2026-07-19-plugin-command-registration.zh.md b/docs/rfc/implemented/feature/2026-07-19-plugin-command-registration.zh.md new file mode 100644 index 0000000000..9633b20aef --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-plugin-command-registration.zh.md @@ -0,0 +1,80 @@ +# RFC:插件拥有的人类命令注册 + +Status: implemented + +[English](2026-07-19-plugin-command-registration.md) | 中文 + +## 问题 + +TUI 拥有七个斜杠命令,而 ACP 定义了标准命令目录与调用形态。如果命令名、帮助文本、自动补全、分派和取消都留在各适配器内部,每个新命令都需要修改适配器,可选插件无法贡献命令,两个前端也会逐渐偏离。把斜杠输入当作普通模型提示同样不安全:用户可见的直接操作可能意外消耗 token,或让模型重新解释未知命令。 + +共享机制必须仍是 UI 关注点,而不是模型工具或智能体循环分支。它还需要精确的逐智能体可见性、可安全 HMR 移除、逐会话 ACP 发现、直接结果渲染和请求作用域取消,同时不得把命令文本或输出加入模型历史。 + +## 决策 + +位于 `packages/ui/commands/` 的 `@deepseek-ai/dsh-commands` 是产品命令注册表。终端与 ACP 应用 bundle(组合包)把它挂载在消费该服务的前端旁,SDK 项目 helper(辅助器)在直接搭建 ACP 时也会生成同一服务;无执行器、无 UI 的智能体 spine(主干)保持独立。TUI 与 ACP 注入该服务,命令生产者只依赖注册表及其操作的领域。 + +### 注册表契约 + +`CommandDefinition` 包含不带 `/` 的小写名称、非空描述、可选的非结构化输入提示、可选的非空界面列表,以及可取消处理器。省略界面时解析为 `tui` 与 `acp`。注册会校验并分离元数据、冻结有效定义,并返回准确的 Cordis effect disposer(副作用释放器)。同一层中的重复名称会失败。 + +`list(agent, surface)` 在界面过滤与作用域遮蔽后返回不可变、按名称排序的描述符。`find(agent, surface, name)` 解析有效定义。`execute(agent, surface, line, signal)` 解析并运行可见定义,返回分离后的 `success` 或 `error` 结果;无效语法、未知名称和对该界面隐藏的定义返回 `undefined`,由适配器拥有直接错误文本。 + +`parseCommand(line)` 要求 `/` 位于第零字节,后接由字母、数字、`_` 或 `-` 组成的小写 ASCII 名称,并以空白或输入末尾结束。它把适配器交付的完整后缀保留为 `rawInput`,包括分隔空白。每个命令插件自行拥有后续语法决策。 + +### 作用域与生命周期 + +无作用域注册是全局注册。挂载在智能体上下文之下并注入 `commands` 的插件会继承该智能体的作用域键与生命周期,因此其定义仅为该准确智能体遮蔽同名全局定义。子插件自行声明 `commands` 注入,因为 `agent.ctx` 有意只继承核心智能体循环的依赖界面;仅为了实现作用域注册而让循环依赖 UI 服务会倒置依赖图。 + +注册和移除会发出未过滤的 `commands/change` 注册表通知。适配器重新计算每个实时智能体的有效视图,而不尝试推断某次变更影响哪些会话。Cordis 所有权会在生产者、UI 实例或智能体作用域卸载时移除定义,因此 HMR 不会留下陈旧的发现项或处理器。 + +### 直接分派与取消 + +命令在仅面向人类的命令平面中运行。输入不会成为 `user/message`,输出不会成为会话事件,两者都不会发送给模型。处理器接收准确的目标智能体、界面、原始输入和请求拥有的 `AbortSignal`。信号中止时,注册表不再等待不合作的处理器;处理器仍负责停止已经启动的外部副作用。 + +预期的处理器失败返回 `CommandResult.error`。抛出的异常或格式错误的结果仍是适配器可见的命令失败,而不是模型消息。该边界有意分离 UI 输出与持久领域变更:例如目标命令可以改变 `ctx.goals`,但持久状态由目标服务拥有。 + +### TUI 映射 + +TUI 把 `help`、`clear`、`cancel`、`reasoning`、`tools`、`redraw` 和 `exit` 注册为智能体作用域命令定义,不再对字符串执行 switch。自动补全与帮助视图读取实时 `tui` 目录,因此插件命令会随其副作用出现和消失。任何以 `/` 开头的提交行都留在命令平面;未知输入产生终端警告,不会落入 `Agent.send()` 或 `Agent.steer()`。 + +每个提交的命令拥有一个 `AbortController`。TUI 释放会中止未完成的分派、移除本地定义,并等待命令生产者 fiber(纤程)后再完成清理。 + +### ACP 映射 + +桥接遵循当前的 [ACP v1 斜杠命令契约](https://agentclientprotocol.com/protocol/v1/slash-commands)。`session/new` 与 `session/load` 发出准确智能体的完整 `available_commands_update` 快照;每次注册表变更都会为每个实时会话发出替换快照。名称、描述和可选非结构化输入提示直接映射到 `AvailableCommand`。 + +ACP 允许命令提示携带额外的受支持内容块。桥接应用普通的无损 `text` 与 `resource_link` 扁平化,然后在结果以 `/` 开头时进入命令平面。不支持的提示块由现有能力边界拒绝。已知命令直接执行;未知或格式错误的斜杠输入返回直接错误,绝不会到达模型。成功文本、预期错误和抛出失败的诊断作为实时 `agent_message_chunk` 输出流式发送,并以 `end_turn` 结束请求。 + +每个 ACP 会话同时只能有一个模型提示或直接命令进行中,各会话彼此独立。当直接命令拥有请求时,`session/cancel` 会中止它;只有智能体提示才调用 `Agent.cancel()`,因此取消命令不会销毁无关的排队或注入智能体工作。连接清理会先中止命令,再释放所拥有的智能体。 + +## 测试 + +注册表测试覆盖语法边界、不可变规范化、默认和显式界面、确定性排序、全局与作用域遮蔽、重复拒绝、准确释放、变更通知回滚、直接调用、预期和格式错误结果、同步与异步失败,以及每种中止时序边沿;该源文件达到逐文件 100% 语句、分支、函数和行覆盖率。 + +TUI 测试覆盖全部迁移后的内置命令、实时插件发现、帮助与自动补全刷新、直接结果、未知命令拒绝、原始输入交付、定义移除、启动回滚和释放取消。ACP 测试使用真实 SDK 连接、智能体工厂、循环与 JSONL 持久化,验证创建/加载快照、动态更新、作用域多会话目录、受支持块扁平化、直接成功/错误/失败、未知命令隔离、取消,以及不存在模型请求或会话消息。SDK helper 测试固定直接 ACP 组合。无密钥 ACP 与终端快照固定新的协议和渲染记录形态。 + +## 考虑过的替代方案 + +- **保留适配器本地 switch**——不予采纳,因为可选插件无法贡献发现与行为,除非修改每个前端。 +- **把人类命令表示为模型工具**——不予采纳,因为发现与直接调用属于人类 UI 行为;经由模型路由会增加延迟、token 成本和重新解释。 +- **把注册表放入核心智能体主干**——不予采纳,因为无头和 JSON-RPC 智能体不消费它,而两个 UI 应用组合包可以显式组合它。 +- **让 `dsh-agent-loop` 注入 commands**——不予采纳,因为循环不执行也不发现人类命令。智能体作用域生产者改为在子插件中声明 UI 依赖。 +- **把未知斜杠输入发送给模型**——不予采纳,因为输入错误或不可用的直接操作必须可预测地失败,而不能改变执行平面。 +- **持久化通用命令输入与输出**——不予采纳,因为适配器提示不是模型可见状态。改变持久行为的处理器会调用拥有该状态的领域 API,由后者记录自己的事件。 +- **把 ACP 命令限制为单个文本块**——不予采纳,因为 ACP v1 允许附带内容,而桥接已有无损的已接纳块转换。 + +## 后果 + +- 命令生产者是普通的可移除插件,TUI 与 ACP 共享一个经过校验的目录和分派契约。 +- 智能体特定定义保留现有扁平作用域与遮蔽语义,不引入核心到 UI 的依赖。 +- 未知斜杠输入与命令输出是确定性 UI 行为,直接模型 token 成本为零。 +- ACP 客户端在创建、加载、注册和 HMR 移除后收到当前的逐会话快照。 +- 直接命令取消与模型轮次取消彼此隔离。 + +## 已知限制与延期工作 + +- 输入元数据仅为 ACP 当前的非结构化文本提示。类型化表单、参数模式和补全提供器仍由命令拥有,或需要后续协议扩展。 +- 通用命令输出仅实时存在,TUI 重启或 ACP 重新连接后不会重建。 +- 注册表取消会立即停止等待,但外部工作只有在处理器配合信号时才会停止。 +- 已发布的行式 `dsh-stdio` 与 JSON-RPC SDK 前端不暴露命令平面;只有 TUI 和 ACP 消费它。 diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl index ce3fe8acef..50d688089e 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-mount","title":"Mount plugin into live cordis runtime","kind":"execute","status":"in_progress","rawInput":{"code":"return { name: 'snapshot-marker', apply() {} }"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"advanced-mount","status":"completed","content":[{"type":"content","content":{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/bash-spill/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/stdout.golden.jsonl index d9d2632bd0..875ff05303 100644 --- a/examples/acp-agent/tests/snapshots/bash-spill/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-spill/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_spill","title":"node -e \"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\"","kind":"execute","status":"in_progress","rawInput":"node -e \"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\"","content":[{"type":"content","content":{"type":"text","text":"Print large deterministic output"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_spill","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\nSPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: {{spillLocator:bash.txt}}. Use read with offset/limit, or grep this path to search within it.)\n```"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl index 4111ecc8de..6c6e1d6158 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.golden.jsonl index 11178b9bc7..1e1131398b 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_wait","title":"node -e \"setInterval(() => {}, 1000)\"","kind":"execute","status":"in_progress","rawInput":"node -e \"setInterval(() => {}, 1000)\"","content":[{"type":"content","content":{"type":"text","text":"Wait until cancellation"}}]}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/cancel/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/cancel/stdout.golden.jsonl index 4146e8804d..0277ef2f90 100644 --- a/examples/acp-agent/tests/snapshots/cancel/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"partial"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl index f3bd0b345c..2e06d6839a 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.golden.jsonl index 3d25d176ac..a41a9b8b3b 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl index 47dc73536f..4e2f95accf 100644 --- a/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":4,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.golden.jsonl index 321c5499a2..50c05a4d18 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"Inspect cordis runtime: api: tools","kind":"read","status":"in_progress"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n }\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export type ContextEnvelope = 'context' | 'raw';\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/error-finish/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/error-finish/stdout.golden.jsonl index 540eb2338a..80ab62b674 100644 --- a/examples/acp-agent/tests/snapshots/error-finish/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/error-finish/stdout.golden.jsonl @@ -1,3 +1,4 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"error":{"code":-32603,"message":"Internal error: turn failed: simulated provider error (HTTP 401)"}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl index 92743b8133..e169016a1c 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl index c8a9b320f0..5a60174abb 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl index fab47cc857..451fe781dd 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl index d92ed5520b..39ddf764de 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl index 44ce1184e9..2f2e1bc667 100644 --- a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl index 712e8e5c3b..3fed936cfc 100644 --- a/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl index d06162a005..51bb5f7c65 100644 --- a/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl index 270d1ace7c..9e0d057b2a 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl index 1cac540a29..927bb99975 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/handshake/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/handshake/stdout.golden.jsonl index fb4f7cbbc5..1390e424d6 100644 --- a/examples/acp-agent/tests/snapshots/handshake/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/handshake/stdout.golden.jsonl @@ -1,2 +1,3 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.golden.jsonl index af42092168..89e476b3e8 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.golden.jsonl index bed3d3a03a..b13a498bc0 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.golden.jsonl index 48b5df1ac2..5acbeb7202 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.golden.jsonl index 74f4b9ea10..623127e083 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.golden.jsonl index 29023a8d45..19ec84b738 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.golden.jsonl @@ -1,3 +1,4 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.golden.jsonl index 15bf48cb81..3f75a15f57 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.golden.jsonl index 39cf7df191..bf478888b4 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl index 7870b73dc2..b8b6abe2c3 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.golden.jsonl index cece2795f7..c8c34ab6ec 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.golden.jsonl index 1a0e83d191..8e303806a9 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.golden.jsonl index 29023a8d45..19ec84b738 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.golden.jsonl @@ -1,3 +1,4 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.golden.jsonl index 15a91d1af3..6107da23da 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.golden.jsonl index 821d648791..ef02915d01 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/model-switching/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/model-switching/stdout.golden.jsonl index 291525f825..e3a5aacbeb 100644 --- a/examples/acp-agent/tests/snapshots/model-switching/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/model-switching/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/multi-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/multi-turn/stdout.golden.jsonl index 9a55a86f02..57dd33f320 100644 --- a/examples/acp-agent/tests/snapshots/multi-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/multi-turn/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.golden.jsonl index 8680db3d30..53048fa11d 100644 --- a/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_read_a","title":"Read a.txt","kind":"read","status":"in_progress","locations":[{"path":"a.txt","line":1}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_read_b","title":"Read b.txt","kind":"read","status":"in_progress","locations":[{"path":"b.txt","line":1}]}}} diff --git a/examples/acp-agent/tests/snapshots/permission-switching/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/permission-switching/stdout.golden.jsonl index 358e81f076..2232cd3a1c 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/permission-switching/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} diff --git a/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.golden.jsonl index 8469933a94..bc40beb3fe 100644 --- a/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_1","title":"Update todo list","kind":"other","status":"in_progress","rawInput":[{"content":"watch the kettle boil","status":"in_progress"}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"plan","entries":[{"content":"watch the kettle boil","priority":"medium","status":"in_progress"}]}}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/skill-load/stdout.golden.jsonl index a1b4b8c0cb..8436971982 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Load the requested skill."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_skill_load","title":"Load skill snapshot-skill","kind":"read","status":"in_progress","rawInput":"snapshot-skill"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl index e2941dd851..a332ea7a7c 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl index e5cc8bfa90..ded1ec01cc 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl index bd4fb81d4a..d25e78d0db 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl index 2b77e856e6..d1f79bacac 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl index c717c3182a..dc7b4dbe25 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/todo-plan/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/todo-plan/stdout.golden.jsonl index 8771e50182..0cae765592 100644 --- a/examples/acp-agent/tests/snapshots/todo-plan/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/todo-plan/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.golden.jsonl index 2c19d8feb9..dce9237834 100644 --- a/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl index 03f482bcc6..73604add34 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/stdout.golden.jsonl index a55c6d6e01..8108f47bb0 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_workspace_read","title":"Read nested/task.txt","kind":"read","status":"in_progress","locations":[{"path":"nested/task.txt","line":1}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_workspace_read","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl index 4e8db74ba6..ff2610f1f5 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index c0537021e8..340d129bcf 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -9,6 +9,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import * as AgentCore from '@deepseek-ai/dsh-agent-spine-demo' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import WorkerCodeRuntime from '@deepseek-ai/dsh-code-runtime-worker' +import CommandService from '@deepseek-ai/dsh-commands' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' @@ -179,6 +180,7 @@ async function mountScenarioContext( await ctx.plugin(ToolSubagent, { provider: 'spawn', toolName: 'subagent', enableRunInBackground: false }) await ctx.plugin(WorkerWorkflowEngine, { provider: 'spawn' }) await ctx.plugin(ToolWorkflow) + await ctx.plugin(CommandService) if (scenario.composition === 'code' || scenario.composition === 'advanced') { await ctx.plugin(WorkerCodeRuntime, {}) } diff --git a/knip.json b/knip.json index c97ea4edea..bb2fb23458 100644 --- a/knip.json +++ b/knip.json @@ -126,6 +126,10 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/ui/commands": { + "entry": ["tests/**/*.spec.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, "packages/examples/stdio-demo": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index bb3dc9c3a5..d2e88e0707 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -198,6 +198,28 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'commands', + summary: 'Human-command registry.', + methods: [ + { + signature: 'register(definition: CommandDefinition): () => void', + jsDoc: '/**\n * Register a global or calling-agent-scoped command.\n * @param definition - discovery metadata, surface mask, and direct UI handler.\n * @returns the exact effect disposer that unregisters this definition.\n */', + }, + { + signature: 'list(agent: Agent, surface: CommandSurface): readonly CommandDescriptor[]', + jsDoc: '/**\n * List the effective immutable command descriptors for one agent and surface.\n * @param agent - exact receiving agent and scoped-layer key.\n * @param surface - UI adapter requesting discovery metadata.\n * @returns name-sorted descriptors after scoped shadowing and surface filtering.\n */', + }, + { + signature: 'find(agent: Agent, surface: CommandSurface, name: string): CommandDefinition | undefined', + jsDoc: '/**\n * Resolve one effective command definition.\n * @param agent - exact receiving agent and scoped-layer key.\n * @param surface - UI adapter performing the lookup.\n * @param name - command name without a slash.\n * @returns the scoped shadow or global definition when visible on the surface.\n */', + }, + { + signature: 'async execute( agent: Agent, surface: CommandSurface, line: string, signal: AbortSignal, ): Promise', + jsDoc: '/**\n * Parse and execute a known command without sending it to the model.\n * @param agent - exact receiving agent.\n * @param surface - dispatching UI adapter.\n * @param line - complete slash-command line.\n * @param signal - cancellation signal owned by the UI request.\n * @returns a detached result, or `undefined` when syntax/name/surface does not resolve.\n */', + }, + ], + }, { key: 'compact', summary: 'Abstract compaction service.', @@ -786,6 +808,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * Ask composed answerers for one decision. Return an outcome to claim the\n * request or call `next()`; failure yields the fail-closed default.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param req - the pending decision (agent, tool identity, reason, signal).\n * @mode waterfall\n */', summary: 'Ask composed answerers for one decision.', }, + { + name: 'commands/change', + mode: 'emit', + signature: '\'commands/change\'(): void', + jsDoc: '/**\n * A command was registered or unregistered. This is an unfiltered registry\n * notification because a global or scoped change may affect any UI view.\n * @mode emit\n */', + summary: 'A command was registered or unregistered.', + }, { name: 'fs/edit-intent', mode: 'waterfall', @@ -1108,6 +1137,30 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CollectedOutput', declaration: 'export interface CollectedOutput {\n text: string;\n truncated: boolean;\n spillPath?: string;\n}', }, + { + name: 'CommandDefinition', + declaration: 'export interface CommandDefinition {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly surfaces?: readonly CommandSurface[];\n readonly handler: (invocation: CommandInvocation) => CommandResult | Promise;\n}', + }, + { + name: 'CommandDescriptor', + declaration: 'export interface CommandDescriptor {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly surfaces: readonly CommandSurface[];\n}', + }, + { + name: 'CommandInputDescriptor', + declaration: 'export interface CommandInputDescriptor {\n readonly hint: string;\n}', + }, + { + name: 'CommandInvocation', + declaration: 'export interface CommandInvocation {\n readonly agent: Agent;\n readonly surface: CommandSurface;\n readonly rawInput: string;\n readonly signal: AbortSignal;\n}', + }, + { + name: 'CommandResult', + declaration: 'export type CommandResult = {\n readonly kind: \'success\';\n readonly text?: string;\n} | {\n readonly kind: \'error\';\n readonly text: string;\n};', + }, + { + name: 'CommandSurface', + declaration: 'export type CommandSurface = \'tui\' | \'acp\' | (string & {});', + }, { name: 'CompactAgentContext', declaration: 'export interface CompactAgentContext {\n session: Session;\n options: {\n provider?: string;\n model?: string;\n };\n}', diff --git a/packages/examples/README.md b/packages/examples/README.md index 5703039d94..58cffeabac 100644 --- a/packages/examples/README.md +++ b/packages/examples/README.md @@ -5,9 +5,9 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling | Package | npm name | Role | |---|---|---| | `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin (`timer` + `llm` + sessions + system-prompt + tools + skills + agents + invariants + `tool-bash` + workspace-context + `tool-skill` + `agent-loop`) | -| `stdio-demo/` | `@deepseek-ai/dsh-stdio-demo` | Terminal chat app: the spine + JSONL persistence + TTY-selected `dsh-tui`/`dsh-stdio` front door + a pre-created `main` agent, with a boot `bin` | +| `stdio-demo/` | `@deepseek-ai/dsh-stdio-demo` | Terminal chat app: the spine + command registry + JSONL persistence + TTY-selected `dsh-tui`/`dsh-stdio` front door + a pre-created `main` agent, with a boot `bin` | | `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | Headless one-shot app: the spine + JSONL persistence + a pre-created `main` agent, with text and DSH-native JSON output | -| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` | +| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + command registry + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` | | `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | Bin-only runtime that boots an external `cordis.yml` for the stdio JSON-RPC SDK client | `agent-spine-demo` is the shared bundle; `stdio-demo`, `cli-demo`, and `acp-demo` compose it with terminal, headless one-shot, and ACP front doors and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches. diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index 1dbda9a08a..9ff052036c 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -11,6 +11,7 @@ stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it | Plugin | Why | |---|---| | `@deepseek-ai/dsh-agent-spine-demo` | the spine, pre-creating **no** agents (ACP `session/new` creates them on demand) | +| `@deepseek-ai/dsh-commands` | the human-command registry used for ACP discovery and direct slash dispatch | | `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by clients that can complete ACP elicitation requests | | `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log (the bridge advertises `loadSession`) | | `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC and provides ACP-backed user answers when a leaf explicitly exposes a user-question tool | diff --git a/packages/examples/acp-demo/package.json b/packages/examples/acp-demo/package.json index 28d057c0a0..f74617f306 100644 --- a/packages/examples/acp-demo/package.json +++ b/packages/examples/acp-demo/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-acp-demo", - "description": "ACP server app: the agent-spine-demo bundle + JSONL persistence + the ACP bridge (no stdout logger, no hmr, no pre-created agents), with a bin to boot a leaf cordis.yml over JSON-RPC stdio", + "description": "ACP server app: agent spine + human commands + JSONL persistence + ACP bridge (no stdout logger, hmr, or pre-created agents), with a JSON-RPC stdio bin", "version": "0.0.1", "private": true, "type": "module", @@ -34,6 +34,7 @@ "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-app-boot": "^0.0.1", "@deepseek-ai/dsh-acp": "^0.0.1", + "@deepseek-ai/dsh-commands": "^0.0.1", "@deepseek-ai/dsh-agent-spine-demo": "^0.0.1", "@deepseek-ai/dsh-workspace-context": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", @@ -47,6 +48,7 @@ "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-acp": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index 6785e5f957..e3eac668a4 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -1,7 +1,7 @@ /** * The ACP server app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}), - * JSONL session persistence, and the {@link @deepseek-ai/dsh-acp} bridge. It - * writes nothing to stdout. + * human-command registry, JSONL session persistence, and the + * {@link @deepseek-ai/dsh-acp} bridge. It writes nothing to stdout. * It pre-creates no agents and leaves adapters, executors, and optional tools to * the leaf, which must likewise avoid stdout loggers. Named exports are * required so Loader retains this plugin's `Config` schema (see @@ -12,6 +12,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import * as acp from '@deepseek-ai/dsh-acp' +import CommandService from '@deepseek-ai/dsh-commands' import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' @@ -87,6 +88,7 @@ export const Config: z = z.object({ * from the provider/model pair. No logger, no `hmr` — stdout stays pure. */ export function apply(ctx: Context, config: Config): void { + ctx.plugin(CommandService) ctx.plugin(agentCore, agentCore.pickSpineConfig(config)) ctx.plugin(UserInteractionService) ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT }) diff --git a/packages/examples/acp-demo/tsconfig.json b/packages/examples/acp-demo/tsconfig.json index b0e537574a..78090f6273 100644 --- a/packages/examples/acp-demo/tsconfig.json +++ b/packages/examples/acp-demo/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../ui/acp" }, + { + "path": "../../ui/commands" + }, { "path": "../../core/agent" }, diff --git a/packages/examples/stdio-demo/README.md b/packages/examples/stdio-demo/README.md index ba4fc10101..38a24918af 100644 --- a/packages/examples/stdio-demo/README.md +++ b/packages/examples/stdio-demo/README.md @@ -11,6 +11,7 @@ A terminal chat always wants the same cluster, so the package owns it rather tha | Plugin | Why it is here | |---|---| | `@deepseek-ai/dsh-agent-spine-demo` | the spine, pre-creating a `main` agent from this app's provider/model pair with `process.cwd()` as the fresh session cwd and carrying its `persona` | +| `@deepseek-ai/dsh-commands` | the human-command registry consumed by the TUI front door and optional command plugins | | `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` | | `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools | | `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool | @@ -79,7 +80,7 @@ Swap `llm-deepseek` for a `mock-llm` leaf plugin and you have the echo demo — ### Composed terminal agent request -**What the model sees**: Through `dsh-agent-spine-demo`, the `main` agent receives the harness identity, configured persona, skill catalog, and visible tools; this app also composes the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user). Each terminal submission becomes a user message; submissions made while the agent runs steer the active turn. +**What the model sees**: Through `dsh-agent-spine-demo`, the `main` agent receives the harness identity, configured persona, skill catalog, and visible tools; this app also composes the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user). Each ordinary terminal submission becomes a user message; submissions made while the agent runs steer the active turn. TUI commands remain outside model context. **Token effect**: Child prompt and schema costs repeat per request; user input and tool history grow until compaction. Terminal banners, logger output, cards, and rendered transcripts add zero model tokens. diff --git a/packages/examples/stdio-demo/package.json b/packages/examples/stdio-demo/package.json index 94554e3f8e..b8285dc351 100644 --- a/packages/examples/stdio-demo/package.json +++ b/packages/examples/stdio-demo/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-stdio-demo", - "description": "Terminal chat app: agent spine + JSONL persistence + TTY pi-tui/readline front-door selection + pre-created main agent", + "description": "Terminal chat app: agent spine + human commands + JSONL persistence + TTY pi-tui/readline front-door selection + pre-created main agent", "version": "0.0.1", "private": true, "type": "module", @@ -36,6 +36,7 @@ "@deepseek-ai/dsh-app-boot": "^0.0.1", "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-loop": "^0.0.1", + "@deepseek-ai/dsh-commands": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-agent-spine-demo": "^0.0.1", "@deepseek-ai/dsh-workspace-context": "^0.0.1", @@ -56,6 +57,7 @@ "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/examples/stdio-demo/src/index.ts b/packages/examples/stdio-demo/src/index.ts index 531c4a3d5e..fecdaf8547 100644 --- a/packages/examples/stdio-demo/src/index.ts +++ b/packages/examples/stdio-demo/src/index.ts @@ -1,9 +1,9 @@ /** * The stdio chat app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}) plus the - * coupled front-door cluster a terminal chat needs — TTY-selected pi-tui/readline - * presentation, JSONL session persistence, the user-interaction seam with its - * `ask_user_question` tool, and one pre-created agent whose exact shared - * agent/session identity the selected UI drives under its `main` display label. + * coupled front-door cluster a terminal chat needs — the command registry, + * TTY-selected pi-tui/readline presentation, JSONL session persistence, the + * user-interaction seam with its `ask_user_question` tool, and one pre-created + * agent whose exact shared identity the selected UI drives as `main`. * Swappable adapters, executors, optional tools, and HMR stay in the leaf. This * Loader plugin intentionally exposes named exports only; a default export * would hide its `Config` schema (see docs/postmortem/0001). @@ -16,6 +16,7 @@ import ConsoleExporter from '@cordisjs/plugin-logger-console' import z from 'schemastery' import { SessionId } from '@deepseek-ai/dsh-session' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' +import CommandService from '@deepseek-ai/dsh-commands' import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' @@ -145,6 +146,7 @@ export function composeTerminalApp(ctx: Context, config: Config, isTTY: boolean) const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`) const mode = resolveTerminalMode(config.ui, isTTY) if (mode === 'readline') ctx.plugin(ConsoleExporter) + ctx.plugin(CommandService) ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT }) ctx.plugin(UserInteractionService) if (mode === 'tui') { diff --git a/packages/examples/stdio-demo/tsconfig.json b/packages/examples/stdio-demo/tsconfig.json index fc6711ffb9..61646af70f 100644 --- a/packages/examples/stdio-demo/tsconfig.json +++ b/packages/examples/stdio-demo/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../core/session" }, + { + "path": "../../ui/commands" + }, { "path": "../agent-spine-demo" }, diff --git a/packages/sdk/helper/README.md b/packages/sdk/helper/README.md index 5b5608cdf0..f893f56e56 100644 --- a/packages/sdk/helper/README.md +++ b/packages/sdk/helper/README.md @@ -6,7 +6,7 @@ The package owns the builtin typed-spec catalog, provider/app behavior entities, All business and document validation completes before commit writes any affected file. Commit detects external edits made after the session opened, but deliberately provides no cross-file rollback after writing starts. -Builtin features are provider, bash, app, persistence, HMR, filesystem, todo, skill, web, subagent, workflow, compaction, hooks, repeat-tool guard, timeout policy, and ask-user. The catalog owns feature options, required and non-default Cordis plugin config, feature requirements, resource contribution, and round-trip markers; create and config use the same registry and configurator. +Builtin features are provider, bash, app, persistence, HMR, filesystem, todo, skill, web, subagent, workflow, compaction, hooks, repeat-tool guard, timeout policy, and ask-user. The catalog owns feature options, required and non-default Cordis plugin config, feature requirements, resource contribution, and round-trip markers; create and config use the same registry and configurator. The ACP app option contributes the human-command and user-interaction services before the bridge. `SdkProject.open()` requires only readable root `package.json` and `cordis.yml`. A Cordis config entry anchors feature installation; a package present only through a linked NPM dependency closure leaves the feature absent. Once an owned Cordis config entry exists, an incomplete resource shape is `inconsistent` and cannot be modified automatically. diff --git a/packages/sdk/helper/src/features/builtin/app.ts b/packages/sdk/helper/src/features/builtin/app.ts index e4ff11af20..4e72ba5a41 100644 --- a/packages/sdk/helper/src/features/builtin/app.ts +++ b/packages/sdk/helper/src/features/builtin/app.ts @@ -73,6 +73,10 @@ class AppOption extends FeatureOption { case 'acp': return new ProjectContribution([ ...appProjectResources(profile, this.id), + ...npmCordisConfigEntry(ID, { + id: 'commands', + name: '@deepseek-ai/dsh-commands', + }), ...npmCordisConfigEntry(ID, { id: 'user-interaction', name: '@deepseek-ai/dsh-user-interaction', diff --git a/packages/sdk/helper/tests/project.spec.ts b/packages/sdk/helper/tests/project.spec.ts index 655b308911..b0a19477a0 100644 --- a/packages/sdk/helper/tests/project.spec.ts +++ b/packages/sdk/helper/tests/project.spec.ts @@ -283,6 +283,7 @@ describe('SdkProject and ProjectEditSession', () => { edit.configureFeature(registry.get(featureId('app')), selection('app', ['acp'])) const acp = (await edit.commit()).project expect(acp.profile.runInterface).toBe('acp') + expect(acp.cordis.entry('commands')).toMatchObject({ name: '@deepseek-ai/dsh-commands' }) expect(acp.packageManifest().scripts).toMatchObject({ dev: 'dsh-sdk dev index.ts', start: 'dsh-sdk start index.js', diff --git a/packages/ui/README.md b/packages/ui/README.md index 3dd26d80a8..0e7cec58aa 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -5,6 +5,7 @@ Integrations that expose the agent to an external editor or client. These are ** | Package | Role | ctx key | |---|---|---| | `acp/` | Agent Client Protocol bridge: serves the agent to an ACP editor (Zed) over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) | +| `commands/` | Human-command registry: discovery metadata, scoped shadowing, surface filtering, cancellation, and direct UI dispatch | `ctx.commands` | | `user-approval/` | One-shot user-approval mechanism, closed outcome vocabulary, audit events, and per-session approval policy | `ctx.approval` | | `permission/` | User-facing permission presets (`workspace-write`/`danger-full-access`): one product-level select bundling the sandbox-mode and approval-policy knobs, written through to their session events | `ctx.permission` | | `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` | @@ -14,7 +15,7 @@ Integrations that expose the agent to an external editor or client. These are ** | `jsonrpc/` | Stdio JSON-RPC server for out-of-process SDK clients | (drives `ctx.agents`) | | `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) | -A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). The [`stdio`](stdio/README.md) and [`tui`](tui/README.md) plugins are the two terminal front doors: one is line-oriented for pipes, the other is interactive for TTYs. App bundles and SDK projects compose the appropriate channel explicitly with the services and tools their product profile selects. +A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). The [`stdio`](stdio/README.md) and [`tui`](tui/README.md) plugins are the two terminal front doors: one is line-oriented for pipes, the other is interactive for TTYs. [`commands`](commands/README.md) is their human-only discovery and dispatch plane; command input and output do not become model messages. App bundles and SDK projects compose the appropriate channel explicitly with the services and tools their product profile selects. `user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with their UI channel owners. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and the app/bridge packages provide concrete providers. diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index 4e19313302..5838e45679 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -8,7 +8,7 @@ It is a **client-driver / UI plugin**, the structured analogue of the terminal ` `apply(ctx, config)` — wires an `AgentSideConnection` (from `@agentclientprotocol/sdk`) to `process.stdin`/`process.stdout` and implements the ACP `Agent` method surface. -The plugin injects `agents`, `sessionPersistence`, `tools`, `userInteraction`, `llm`, and `systemPrompt`, never the concrete loop. Persistence backs `session/load`; the LLM catalog backs model selection; prompt assembly keeps model variables aligned with routing; tool definitions own presentation; user interaction maps agent questions to ACP forms. +The plugin injects `agents`, [`commands`](../commands/README.md), `sessionPersistence`, `tools`, `userInteraction`, `llm`, and `systemPrompt`, never the concrete loop. Persistence backs `session/load`; the command registry backs slash discovery and direct dispatch; the LLM catalog backs model selection; prompt assembly keeps model variables aligned with routing; tool definitions own presentation; user interaction maps agent questions to ACP forms. ### Config @@ -26,10 +26,10 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name: | ACP method | Harness seam | Notes | |---|---|---| | `initialize` | static | negotiate `protocolVersion`; advertise baseline prompt capabilities (`text`, plus `resource_link` rendered as text) and `loadSession: true` | -| `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | creates a new session/agent; N concurrent sessions are allowed, keyed by id; `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, and replays user, assistant, and tool events | -| `session/prompt` | `agent.send()` | supports ACP `text` and `resource_link` blocks; rejects image/audio/embedded resource and empty prompts; one in-flight prompt PER session (independent); settles on the OWNING turn's end (a turn that ends in `error` rejects the RPC) | -| `session/cancel` | `agent.cancel()` | the queue-aware cancel: aborts a running step, clears queued + steering work, and drops a turn about to start, then settles the prompt `cancelled` — for ONLY that session (a cancel never touches another session's stream or prompt) | +| `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, and tool events, and re-advertises commands | +| `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`; unsupported content and empty prompts are rejected; one request is in flight per session | +| `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, and tool render intents | | `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 | | `session/request_permission` | `approval/request` listener | answers one-shot allow/reject requests for bridge-owned calls; foreign or call-less requests delegate and fail closed if unanswered — see "Permission prompts" | @@ -39,6 +39,12 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name: One id-keyed record map plus exact agent-object checks route every event, prompt, cancel, and approval to one session. Each session permits one in-flight prompt; teardown drains all sessions in parallel. See the [multi-session RFC](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md). +## Human commands + +After `session/new` and `session/load`, the bridge emits ACP's full `available_commands_update` snapshot for that exact agent. A global or scoped registry change refreshes every live session from its independently resolved view, so clients replace rather than merge cached catalogs. Names omit the slash; descriptions and optional unstructured-input hints map directly to ACP `AvailableCommand`. + +ACP v1 permits a command prompt to carry additional content blocks. The bridge applies its ordinary lossless flattening for supported `text` and `resource_link` blocks, then dispatches when the result begins with `/`. Known commands execute without a model request. Unknown or malformed slash input returns a direct error instead of falling back to the model. Expected handler errors, thrown failures, and successful text stream as UI-only `agent_message_chunk` output and end the request; cancellation returns `cancelled`. See the [command RFC](../../../docs/rfc/implemented/feature/2026-07-19-plugin-command-registration.md) and the [ACP v1 slash-command contract](https://agentclientprotocol.com/protocol/v1/slash-commands). + ## Session config options The bridge advertises a `model`-category select in `session/new` and `session/load` when the session has a complete target whose provider is registered. Values encode the complete provider/model pair, are grouped by provider when more than one group is available, and come from `ctx.llm.listProviders()` / `listModels()`. The configured or last-requested model is added when absent because catalogs are advisory and private adapters may accept unlisted ids. A selection changes only that ACP session. Agent-scoped prompt assembly snapshots the selected pair for one step, supplies matching `{{provider}}` / `{{model}}` variables, and the `agent/request` waterfall applies the same pair; a concurrent selection therefore takes effect on the next step instead of splitting prompt text from routing. The resulting request header is the durable record restored by `session/load`; a selection never used by a request remains in-memory only. @@ -98,6 +104,12 @@ The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loa **Token effect**: Prompt tokens are data-dependent and remain in that session's history until compaction. Concurrent ACP sessions keep separate contexts. +### Human commands + +**What the model sees**: Nothing from command discovery, slash input, or command output. A command handler may separately mutate a durable domain whose later state affects model requests. + +**Token effect**: Direct dispatch adds no model tokens and no session message. The mutated domain owns any later prompt or history cost. + ### Human answers and permission decisions **What the model sees**: When optional consumers are loaded, ACP form answers become the exact JSON shape documented by `dsh-tool-ask-user`. Failures become `Error: ACP user questions must come from an agent-owned request`, `Error: ACP user question has no matching session`, `Error: ACP elicitation request failed`, `Error: ask_user_question was cancelled by the user`, `Error: ask_user_question returned no answer`, or `Error: ask_user_question was aborted before the user answered`. Permission decisions control whether another tool yields success or denial. ACP tool cards, terminal output, diffs, and streamed session updates are UI-only. @@ -128,3 +140,4 @@ The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loa - **Prompt content is `text` + `resource_link` only** — image, audio, and embedded-resource blocks are rejected, as is a non-empty `mcpServers` list at `session/new`. - **Terminal cards render completed output** — live incremental streaming and command classification are named follow-ups of [the terminal-rendering RFC](../../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md). - **Permission answers are one-shot only** — the bridge offers `allow_once` / `reject_once`; durable `allow_always` grants and their storage/revocation policy remain deferred to the approval seam. +- **Command output is live-only** — discovery is refreshed after load, but direct command results are not persisted or replayed into a reconnected editor. diff --git a/packages/ui/acp/acp-feature-support.md b/packages/ui/acp/acp-feature-support.md index 34e3caae8a..7eacea9cd3 100644 --- a/packages/ui/acp/acp-feature-support.md +++ b/packages/ui/acp/acp-feature-support.md @@ -10,7 +10,7 @@ Legend: ✅ supported · ⚠️ partial / fallback · ❌ not yet · — n/a. Th ## At a glance -The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, one-shot permission prompts, per-session model selection, and permission presets. The largest **unbuilt** areas are **MCP passthrough**, **slash commands**, and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary). +The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, slash commands, one-shot permission prompts, per-session model selection, and permission presets. The largest **unbuilt** areas are **MCP passthrough** and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary). ## 1. Agent methods (client → agent) @@ -23,8 +23,8 @@ 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/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/prompt` | S | ✅ | ✅ | ✅ | Maps to `agent.send`; one in-flight prompt per session; settles on the owning turn's end. | -| `session/cancel` | S | ✅ | ✅ | ✅ | Queue-aware `agent.cancel`; settles the in-flight prompt `cancelled`, scoped to the one session. | +| `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/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 | ❌ | ✅ | ✅ | Session modes deliberately skipped: config options are the spec's replacement and modes are slated for removal in ACP v2 (see [§6](#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. | | model selection | S | ✅ | ✅ | ✅ | No distinct stable `session/set_model` — model is the `model`-category `session/set_config_option`. Values preserve the provider/model pair, catalogs come from `ctx.llm`, selection is per session, and `session/load` restores the last requested pair. Codex also supports the legacy `unstable_setSessionModel` ext method. | @@ -84,7 +84,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs | `tool_call` | S | ✅ | ✅ | ✅ | Tool-owned presentation (`presentCall`); see [§5](#5-tool-call-rendering). | | `tool_call_update` | S | ✅ | ✅ | ✅ | From `tool/result` via `presentResult`. | | `plan` | S | ❌ | ✅ | ✅ | No agent plan emitted. Both adapters emit real plan entries (Codex's `CodexEventHandler.updatePlan` maps `turn/plan/updated` → `{ sessionUpdate: 'plan', entries }`). | -| `available_commands_update` | S | ❌ | ✅ | ✅ | No slash commands advertised. | +| `available_commands_update` | S | ✅ | ✅ | ✅ | Full effective snapshot after create/load and registry changes; names, descriptions, and unstructured-input hints come from `ctx.commands`. | | `current_mode_update` | S | ❌ | ✅ | ✅ | No session modes. | | `config_option_update` | S | ❌ | ✅ | ✅ | Config options exist (advertised in `session/new`/`session/load`, switched via `session/set_config_option`), but the bridge never pushes agent-initiated changes — an operator default drift is narrated to the MODEL, not echoed to the editor. Future work in the [sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md). | | `usage_update` | S | ❌ | ✅ | ✅ | Token/cost reporting not surfaced (the harness records token usage internally on `assistant/message`). | @@ -142,11 +142,10 @@ Ranked by how commonly the reference adapters ship them and how much UX they unl 1. **Session lifecycle** — `session/list` + `session/delete` (the persistence layer already lists), then `session/resume` / `session/close`. 2. **Agent plan** (`sessionUpdate: 'plan'`) — surface the loop's plan as structured entries. -3. **Slash commands** (`available_commands_update`). -4. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`). -5. **Richer prompt content** — image / embedded `resource` blocks (needs a multimodal model path). -6. **Usage reporting** (`usage_update`) — the harness already records token usage internally (on `assistant/message`). -7. **Editor filesystem delegation** (`fs/read_text_file` / `fs/write_text_file`) — lets the agent see unsaved buffers; lower priority since the harness has direct disk access. +3. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`). +4. **Richer prompt content** — image / embedded `resource` blocks (needs a multimodal model path). +5. **Usage reporting** (`usage_update`) — the harness already records token usage internally (on `assistant/message`). +6. **Editor filesystem delegation** (`fs/read_text_file` / `fs/write_text_file`) — lets the agent see unsaved buffers; lower priority since the harness has direct disk access. ## Out of scope diff --git a/packages/ui/acp/package.json b/packages/ui/acp/package.json index 84e4dcbda9..d56a573698 100644 --- a/packages/ui/acp/package.json +++ b/packages/ui/acp/package.json @@ -29,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-commands": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-permission": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", @@ -46,6 +47,7 @@ "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index e20cd40f10..9fda202688 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -18,6 +18,7 @@ import { RequestError, type Agent as AcpAgent, type AuthenticateRequest, + type AvailableCommand, type CancelNotification, type ContentBlock as AcpContentBlock, type CreateElicitationRequest, @@ -45,6 +46,7 @@ import { import type { ContentBlock, LlmCallConfig, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' import { assertNever, CallId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-commands' import { SessionId } from '@deepseek-ai/dsh-session' // Side-effect type import: resolves `ctx.get('permission')` to the service. import type {} from '@deepseek-ai/dsh-permission' @@ -76,13 +78,22 @@ import { export const name = 'acp' // Interface services back loading, presentation, interaction, and prompt assembly. -export const inject = ['agents', 'sessionPersistence', 'tools', 'userInteraction', 'llm', 'systemPrompt'] +export const inject = ['agents', 'commands', 'sessionPersistence', 'tools', 'userInteraction', 'llm', 'systemPrompt'] /** Preserve invalid-parameter detail in the SDK wire error message. */ function invalidParams(detail: string): RequestError { return RequestError.invalidParams(undefined, detail) } +/** Render arbitrary thrown values without trusting their string coercion. */ +function renderThrown(value: unknown): string { + try { + return String(value) + } catch { + return '' + } +} + /** Preserve failed-turn detail; plain handler errors become a generic wire internal error. */ function internalError(detail: string): RequestError { return RequestError.internalError(undefined, detail) @@ -259,6 +270,8 @@ interface SessionRecord { reject: (error: Error) => void turn: number | undefined } | undefined + /** Abort owner for a direct slash-command request, mutually exclusive with `inflight`. */ + commandAbort: AbortController | undefined /** Last idle switch per knob, anchored before the next prompt assembles. */ pendingSwitches: { preset?: string } } @@ -273,6 +286,7 @@ export function apply(ctx: Context, config: AcpConfig): void { // ACP handlers execute outside this plugin's injection scope, so capture // injected services during apply(); lazy service reads in a handler fail. const agents = ctx.agents + const commands = ctx.commands const llm = ctx.llm const sessionPersistence = ctx.sessionPersistence const logger = ctx.logger @@ -467,6 +481,30 @@ export function apply(ctx: Context, config: AcpConfig): void { }) } + /** Project the effective registry view onto ACP discovery metadata. */ + const availableCommands = (agent: Agent): AvailableCommand[] => commands.list(agent, 'acp').map(command => ({ + name: command.name, + description: command.description, + ...command.input === undefined ? {} : { input: { hint: command.input.hint } }, + })) + + /** Push the protocol's full-snapshot command catalog for one live session. */ + const notifyCommands = (rec: SessionRecord): void => { + notify({ + sessionId: rec.agent.session.id, + update: { + sessionUpdate: 'available_commands_update', + availableCommands: availableCommands(rec.agent), + }, + }) + } + + // Registration and HMR removal can affect global or one scoped view; refresh + // every bridge-owned session and let the registry resolve each exact agent. + ctx.on('commands/change', () => { + for (const rec of sessions.values()) notifyCommands(rec) + }) + /** Settle the in-flight prompt with a stop reason, exactly once (no-op if none pending). */ const settlePrompt = (rec: SessionRecord, reason: StopReason): void => { const inflight = rec.inflight @@ -680,8 +718,10 @@ export function apply(ctx: Context, config: AcpConfig): void { terminalEnabled: terminalOutputCap, target, inflight: undefined, + commandAbort: undefined, pendingSwitches: {}, }) + notifyCommands(requireSession(sessionId)) const configOptions = configOptionsFor(handle.agent, directory) return { sessionId, ...configOptions.length > 0 ? { configOptions } : {} } }, @@ -762,6 +802,7 @@ export function apply(ctx: Context, config: AcpConfig): void { terminalEnabled, target, inflight: undefined, + commandAbort: undefined, pendingSwitches: {}, } sessions.set(sessionId, record) @@ -786,6 +827,7 @@ export function apply(ctx: Context, config: AcpConfig): void { for (const event of agent.session.events) { streamSessionEventUpdate(sessionId, event, notify, replayPresenter, replayTerminal) } + notifyCommands(record) const configOptions = configOptionsFor(agent, directory) return configOptions.length > 0 ? { configOptions } : {} } finally { @@ -796,7 +838,7 @@ export function apply(ctx: Context, config: AcpConfig): void { async prompt(params: PromptRequest): Promise { assertOpen() const rec = requireSession(SessionId(params.sessionId)) - if (rec.inflight !== undefined) { + if (rec.inflight !== undefined || rec.commandAbort !== undefined) { throw invalidParams('a prompt is already in flight for this session') } if (promptHasUnsupportedContent(params.prompt)) { @@ -809,6 +851,52 @@ export function apply(ctx: Context, config: AcpConfig): void { // waiting for a settle that never comes. throw invalidParams('empty prompt') } + // ACP command prompts may carry additional supported content blocks. + // The same lossless flattening used for model prompts supplies their + // unstructured command input; unsupported kinds were rejected above. + const commandLine = text.startsWith('/') ? text : undefined + if (commandLine !== undefined) { + const controller = new AbortController() + rec.commandAbort = controller + try { + const result = await commands.execute(rec.agent, 'acp', commandLine, controller.signal) + if (result !== undefined && result.text !== undefined && result.text !== '') { + notify({ + sessionId: rec.agent.session.id, + update: { + sessionUpdate: 'agent_message_chunk', + content: { + type: 'text', + text: result.kind === 'error' ? `Error: ${result.text}` : result.text, + }, + }, + }) + } else if (result === undefined) { + notify({ + sessionId: rec.agent.session.id, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: `Error: unknown command: ${commandLine}` }, + }, + }) + } + return { stopReason: 'end_turn' } + } catch (error: unknown) { + if (controller.signal.aborted) return { stopReason: 'cancelled' } + const rendered = renderThrown(error) + logger.warn(`acp: command failed: ${rendered}`) + notify({ + sessionId: rec.agent.session.id, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: `Error: command failed: ${rendered}` }, + }, + }) + return { stopReason: 'end_turn' } + } finally { + rec.commandAbort = undefined + } + } // Install the in-flight slot BEFORE send() (send does not synchronously // flip status to running; the session/event listener records the turn // number and settle/rejects it). Capture the log length now as the @@ -835,8 +923,12 @@ export function apply(ctx: Context, config: AcpConfig): void { // settle it, because cancel() may drop the turn before any turn/end is // emitted, and removing this direct settle would move the RPC's // resolution onto a later observer path, changing its timing. - rec.agent.cancel('session/cancel') - settlePrompt(rec, 'cancelled') + if (rec.commandAbort !== undefined) { + rec.commandAbort.abort(new Error('session/cancel')) + } else { + rec.agent.cancel('session/cancel') + settlePrompt(rec, 'cancelled') + } return Promise.resolve() }, @@ -950,6 +1042,7 @@ export function apply(ctx: Context, config: AcpConfig): void { quiescing = (async () => { await Promise.all(recs.map(async (rec) => { settlePrompt(rec, 'cancelled') + rec.commandAbort?.abort(new Error('ACP connection closed')) // Per-agent dispose (the AgentHandle disposer): unregister this agent, // stop its loop (sets disposed + aborts the in-flight step), await // quiescence (the loop exit + final flush), and remove its session — so diff --git a/packages/ui/acp/tests/commands.spec.ts b/packages/ui/acp/tests/commands.spec.ts new file mode 100644 index 0000000000..487b4b59f0 --- /dev/null +++ b/packages/ui/acp/tests/commands.spec.ts @@ -0,0 +1,258 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' +import { SessionId } from '@deepseek-ai/dsh-session' +import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' + +function commandUpdates(harness: BridgeHarness, sessionId: string) { + return harness.sessionUpdates.filter(update => update.sessionId === sessionId + && update.update.sessionUpdate === 'available_commands_update') +} + +function messageText(harness: BridgeHarness, sessionId: string): string { + return harness.sessionUpdates + .filter(update => update.sessionId === sessionId && update.update.sessionUpdate === 'agent_message_chunk') + .map(({ update }) => update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text' + ? update.content.text : '') + .join('') +} + +describe('ACP plugin commands', () => { + let storageDir: string + let harness: BridgeHarness | undefined + + beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-command-')) }) + afterEach(async () => { + if (harness !== undefined) await harness.dispose() + harness = undefined + await rm(storageDir, { recursive: true, force: true }) + }) + + it('publishes a full command snapshot after session creation and refreshes it dynamically', async () => { + harness = await makeBridgeHarness({ storageDir }) + harness.ctx.commands.register({ + name: 'inspect', + description: 'Inspect the session', + input: { hint: '' }, + handler: () => ({ kind: 'success' }), + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + expect(commandUpdates(harness, sessionId).at(-1)?.update).toEqual({ + sessionUpdate: 'available_commands_update', + availableCommands: [{ + name: 'inspect', + description: 'Inspect the session', + input: { hint: '' }, + }], + }) + + const dispose = harness.ctx.commands.register({ + name: 'alpha', + description: 'Alpha command', + surfaces: ['acp'], + handler: () => ({ kind: 'success' }), + }) + await vi.waitFor(() => { + expect(commandUpdates(harness!, sessionId).at(-1)?.update).toMatchObject({ + availableCommands: [{ name: 'alpha' }, { name: 'inspect' }], + }) + }) + dispose() + await vi.waitFor(() => { + expect(commandUpdates(harness!, sessionId).at(-1)?.update).toMatchObject({ + availableCommands: [{ name: 'inspect' }], + }) + }) + }) + + it('re-advertises commands after loading a persisted session', async () => { + const live = await makeBridgeHarness({ storageDir, script: [textResponse('persisted')] }) + await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await live.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'persist this session' }] }) + await live.dispose() + + harness = await makeBridgeHarness({ storageDir }) + harness.ctx.commands.register({ + name: 'loaded', description: 'Loaded command', handler: () => ({ kind: 'success' }), + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + await harness.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] }) + + expect(commandUpdates(harness, sessionId).at(-1)?.update).toMatchObject({ + availableCommands: [{ name: 'loaded', description: 'Loaded command' }], + }) + }) + + it('executes a known single-text command directly and never sends it to the model', async () => { + harness = await makeBridgeHarness({ storageDir }) + const seen = vi.fn(() => ({ kind: 'success' as const, text: 'DIRECT RESULT' })) + harness.ctx.commands.register({ name: 'direct', description: 'Run directly', handler: seen }) + harness.ctx.commands.register({ + name: 'silent', description: 'Return no text', handler: () => ({ kind: 'success' }), + }) + harness.ctx.commands.register({ + name: 'empty', description: 'Return empty text', handler: () => ({ kind: 'success', text: '' }), + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + const response = await harness.client.prompt({ + sessionId, + prompt: [{ type: 'text', text: '/direct raw args ' }], + }) + + expect(response.stopReason).toBe('end_turn') + expect(seen).toHaveBeenCalledWith(expect.objectContaining({ surface: 'acp', rawInput: ' raw args ' })) + expect(messageText(harness, sessionId)).toContain('DIRECT RESULT') + const updatesAfterText = harness.sessionUpdates.length + await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/silent' }] }) + await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/empty' }] }) + expect(harness.sessionUpdates).toHaveLength(updatesAfterText) + expect(harness.adapter.requests).toHaveLength(0) + expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0) + }) + + it('renders expected command errors and rejects unknown slash commands without model fallback', async () => { + harness = await makeBridgeHarness({ storageDir }) + harness.ctx.commands.register({ + name: 'denied', + description: 'Deny directly', + handler: () => ({ kind: 'error', text: 'not allowed now' }), + }) + harness.ctx.commands.register({ + name: 'throws', + description: 'Throw an ordinary error', + handler: () => { throw new Error('handler exploded') }, + }) + harness.ctx.commands.register({ + name: 'hostile', + description: 'Throw a hostile value', + handler: () => { + throw { toString(): string { throw new Error('coercion exploded') } } + }, + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/denied' }] })) + .resolves.toEqual({ stopReason: 'end_turn' }) + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/missing input' }] })) + .resolves.toEqual({ stopReason: 'end_turn' }) + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/throws' }] })) + .resolves.toEqual({ stopReason: 'end_turn' }) + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/hostile' }] })) + .resolves.toEqual({ stopReason: 'end_turn' }) + + expect(messageText(harness, sessionId)).toContain('Error: not allowed now') + expect(messageText(harness, sessionId)).toContain('Error: unknown command: /missing input') + expect(messageText(harness, sessionId)).toContain('Error: command failed: Error: handler exploded') + expect(messageText(harness, sessionId)).toContain('Error: command failed: ') + expect(harness.adapter.requests).toHaveLength(0) + }) + + it('flattens supported command prompt blocks without invoking the model', async () => { + harness = await makeBridgeHarness({ storageDir }) + const command = vi.fn(() => ({ kind: 'success' as const, text: 'combined' })) + harness.ctx.commands.register({ name: 'direct', description: 'Direct', handler: command }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + await expect(harness.client.prompt({ + sessionId, + prompt: [ + { type: 'text', text: '/direct' }, + { type: 'text', text: ' extra' }, + { type: 'resource_link', name: 'input', uri: 'file:///workspace/input.txt' }, + ], + })).resolves.toEqual({ stopReason: 'end_turn' }) + expect(command).toHaveBeenCalledWith(expect.objectContaining({ + rawInput: ' extra\n[resource_link name="input" uri="file:///workspace/input.txt"]\n', + })) + expect(messageText(harness, sessionId)).toContain('combined') + expect(harness.adapter.requests).toHaveLength(0) + }) + + it('maps session cancellation to the in-flight command signal and isolates other sessions', async () => { + harness = await makeBridgeHarness({ storageDir }) + let started!: () => void + const ready = new Promise((resolve) => { started = resolve }) + harness.ctx.commands.register({ + name: 'wait', + description: 'Wait for cancellation', + handler: ({ signal }) => { + started() + return new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve({ kind: 'error', text: 'late abort result' }) }, { once: true }) + }) + }, + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const a = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const b = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + const waiting = harness.client.prompt({ sessionId: a.sessionId, prompt: [{ type: 'text', text: '/wait' }] }) + await ready + await expect(harness.client.prompt({ sessionId: a.sessionId, prompt: [{ type: 'text', text: '/wait' }] })) + .rejects.toThrow(/already in flight/) + await harness.client.cancel({ sessionId: a.sessionId }) + + await expect(waiting).resolves.toEqual({ stopReason: 'cancelled' }) + await expect(harness.client.prompt({ sessionId: b.sessionId, prompt: [{ type: 'text', text: '/missing' }] })) + .resolves.toEqual({ stopReason: 'end_turn' }) + expect(messageText(harness, a.sessionId)).not.toContain('late abort result') + }) + + it('aborts an in-flight command when the ACP bridge is disposed', async () => { + harness = await makeBridgeHarness({ storageDir }) + let started!: () => void + const ready = new Promise((resolve) => { started = resolve }) + let commandSignal: AbortSignal | undefined + harness.ctx.commands.register({ + name: 'wait-dispose', + description: 'Wait for bridge disposal', + handler: ({ signal }) => { + commandSignal = signal + started() + return new Promise(() => {}) + }, + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + const waiting = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/wait-dispose' }] }) + await ready + await harness.acpFiber.dispose() + + expect(commandSignal?.aborted).toBe(true) + await expect(waiting).resolves.toEqual({ stopReason: 'cancelled' }) + }) + + it('resolves scoped command catalogs and execution independently per session', async () => { + harness = await makeBridgeHarness({ storageDir }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const a = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const b = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const agentA = harness.ctx.agents.get(SessionId(a.sessionId)) + if (agentA === undefined) throw new Error('session A has no agent') + await agentA.ctx.inject(['commands'], (commandCtx) => { + commandCtx.commands.register({ + name: 'private', description: 'Only session A', surfaces: ['acp'], + handler: () => ({ kind: 'success', text: 'A ONLY' }), + }) + }) + + await vi.waitFor(() => { + expect(commandUpdates(harness!, a.sessionId).at(-1)?.update).toMatchObject({ availableCommands: [{ name: 'private' }] }) + }) + expect(commandUpdates(harness, b.sessionId).at(-1)?.update).toMatchObject({ availableCommands: [] }) + await harness.client.prompt({ sessionId: a.sessionId, prompt: [{ type: 'text', text: '/private' }] }) + await harness.client.prompt({ sessionId: b.sessionId, prompt: [{ type: 'text', text: '/private' }] }) + expect(messageText(harness, a.sessionId)).toContain('A ONLY') + expect(messageText(harness, b.sessionId)).toContain('unknown command') + }) +}) diff --git a/packages/ui/acp/tests/harness.ts b/packages/ui/acp/tests/harness.ts index 1796cbf868..9b4c0c317a 100644 --- a/packages/ui/acp/tests/harness.ts +++ b/packages/ui/acp/tests/harness.ts @@ -9,6 +9,7 @@ import { CallId, type GenerateOptions, type LlmModelInfo, type LlmProviderInfo, import { LlmAdapter } from '@deepseek-ai/dsh-llm' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import CommandService from '@deepseek-ai/dsh-commands' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' @@ -210,6 +211,7 @@ export async function makeBridgeHarness(options: { await mountAgentLoopTestDependencies(ctx, { systemPrompt: { persona: options.persona ?? '' }, }) + await ctx.plugin(CommandService) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SessionPersistenceJsonl, { root: options.storageDir }) await ctx.plugin(UserInteractionService) diff --git a/packages/ui/acp/tsconfig.json b/packages/ui/acp/tsconfig.json index 387e0d0c53..428e68e01c 100644 --- a/packages/ui/acp/tsconfig.json +++ b/packages/ui/acp/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../core/tools" }, + { + "path": "../commands" + }, { "path": "../user-interaction" }, diff --git a/packages/ui/commands/README.md b/packages/ui/commands/README.md new file mode 100644 index 0000000000..6821d25ed2 --- /dev/null +++ b/packages/ui/commands/README.md @@ -0,0 +1,31 @@ +# @deepseek-ai/dsh-commands + +Plugin-owned human-command registry shared by the TUI and ACP adapters. The [plugin command registration RFC](../../../docs/rfc/implemented/feature/2026-07-19-plugin-command-registration.md) owns the boundary and protocol mapping. + +## Service contract + +`ctx.commands.register(definition)` registers one lowercase command name, description, optional ACP-compatible unstructured-input hint, optional surface list, and abortable handler. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal emits `commands/change` so live adapters can refresh discovery. + +`list(agent, surface)` returns immutable, name-sorted descriptors after scoped shadowing and surface filtering. `find(agent, surface, name)` returns the corresponding definition. `execute(agent, surface, line, signal)` uses `parseCommand()` and runs only a known command, returning `undefined` for invalid syntax, unknown names, or commands hidden from that surface. + +`parseCommand()` recognizes a slash at byte zero, a lowercase name containing letters, digits, `_`, or `-`, and either end-of-input or whitespace. It returns every byte after the name as `rawInput`, including separator whitespace; consumers own their command-specific grammar and may normalize only what that grammar permits. + +Handlers return `success` or `error` plus optional UI text. Results are rendered directly by the adapter and never enter model history. The registry races handler completion against the supplied abort signal, but an uncooperative handler may continue its own external side effects after the caller stops awaiting it. + +## Composition + +The terminal and ACP app bundles mount this service with their consuming front door; the UI-less agent spine does not. Custom compositions that use `dsh-tui`, `dsh-acp`, or a command producer mount `@deepseek-ai/dsh-commands` explicitly. + +## Model Experience + +### Direct human commands + +**What the model sees**: Nothing. Known slash commands execute in the UI command plane, and their `CommandResult` text is not submitted as a user message. Unknown slash-command input is rejected by shipped adapters instead of becoming a model prompt. + +**Token effect**: Command discovery, execution, and UI output add no model tokens. A command plugin may separately mutate a model-visible domain through that domain's durable APIs. + +## Known Limitations and Deferred Work + +- **Only unstructured text input** — the descriptor intentionally matches ACP's current unstructured command input; forms, completion schemas, and typed arguments remain command-owned parsing concerns. +- **No persisted command output** — adapters display results live, but the generic registry does not add them to the session log or reconstruct them after reconnect. +- **Cooperative side-effect cancellation** — dispatch stops awaiting on abort; handlers must honor the signal to stop work that has already escaped into external systems. diff --git a/packages/ui/commands/package.json b/packages/ui/commands/package.json new file mode 100644 index 0000000000..ef7e54e1c1 --- /dev/null +++ b/packages/ui/commands/package.json @@ -0,0 +1,35 @@ +{ + "name": "@deepseek-ai/dsh-commands", + "description": "Plugin-owned human command registry for DeepSeek Harness UI surfaces", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-scope": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/ui/commands/src/index.ts b/packages/ui/commands/src/index.ts new file mode 100644 index 0000000000..97ec0c3587 --- /dev/null +++ b/packages/ui/commands/src/index.ts @@ -0,0 +1,319 @@ +/** + * Plugin-owned human-command registry shared by interactive UI adapters. + * @module @deepseek-ai/dsh-commands + */ + +import { Context, Service } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { scopeOf } from '@deepseek-ai/dsh-scope' +import type { ScopeKey } from '@deepseek-ai/dsh-scope' + +export const name = 'commands' + +const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u +const SURFACE_NAME = /^[a-z][a-z0-9-]*$/u +const DEFAULT_SURFACES = ['tui', 'acp'] as const + +/** A UI adapter capable of listing and executing human commands. */ +export type CommandSurface = 'tui' | 'acp' | (string & {}) + +/** Immutable command input metadata compatible with ACP unstructured input. */ +export interface CommandInputDescriptor { + /** Placeholder shown before the user supplies free-form input. */ + readonly hint: string +} + +/** Invocation passed to one registered command handler. */ +export interface CommandInvocation { + /** Exact agent whose human-facing surface received the command. */ + readonly agent: Agent + /** UI adapter that dispatched the command. */ + readonly surface: CommandSurface + /** Exact text following the registered command name, including separator whitespace. */ + readonly rawInput: string + /** Cancellation signal owned by the dispatching UI request. */ + readonly signal: AbortSignal +} + +/** Expected command outcome rendered directly by the dispatching UI. */ +export type CommandResult = + | { readonly kind: 'success'; readonly text?: string } + | { readonly kind: 'error'; readonly text: string } + +/** Plugin-owned command registration. */ +export interface CommandDefinition { + /** Lowercase command name without the leading slash. */ + readonly name: string + /** Human-readable summary used in discovery UI. */ + readonly description: string + /** Optional free-form input hint advertised to capable clients. */ + readonly input?: CommandInputDescriptor + /** Surfaces exposing this command; omission means both shipped surfaces. */ + readonly surfaces?: readonly CommandSurface[] + /** Execute against the receiving agent without sending the command to the model. */ + readonly handler: (invocation: CommandInvocation) => CommandResult | Promise +} + +/** Handler-free immutable command view returned to UI adapters. */ +export interface CommandDescriptor { + /** Lowercase command name without the leading slash. */ + readonly name: string + /** Human-readable summary used in discovery UI. */ + readonly description: string + /** Optional free-form input hint advertised to capable clients. */ + readonly input?: CommandInputDescriptor + /** Surfaces on which this definition is visible. */ + readonly surfaces: readonly CommandSurface[] +} + +/** Syntactically valid slash command before registry resolution. */ +export interface ParsedCommand { + /** Lowercase command name without the leading slash. */ + readonly name: string + /** Exact text following the command name. */ + readonly rawInput: string +} + +interface RegisteredCommand { + readonly definition: CommandDefinition & { readonly surfaces: readonly CommandSurface[] } + readonly descriptor: CommandDescriptor +} + +declare module 'cordis' { + interface Context { + commands: CommandService + } + + interface Events { + /** + * A command was registered or unregistered. This is an unfiltered registry + * notification because a global or scoped change may affect any UI view. + * @mode emit + */ + 'commands/change'(): void + } +} + +/** + * Parse an exact slash command without normalizing its trailing input. + * + * @param line - Complete candidate command line. + * @returns The parsed command, or `undefined` when the line is not a command. + */ +export function parseCommand(line: string): ParsedCommand | undefined { + const match = /^\/([a-z][a-z0-9_-]*)(?=$|[\t\n\r ])/u.exec(line) + if (match === null) return undefined + const name = match[1] + /* v8 ignore next -- the first capture is required whenever the regular expression matches */ + if (name === undefined) return undefined + return Object.freeze({ name, rawInput: line.slice(match[0].length) }) +} + +/** Convert arbitrary abort reasons to one stable rejected Error. */ +function abortError(signal: AbortSignal): Error { + if (signal.reason instanceof Error) return signal.reason + return new Error(typeof signal.reason === 'string' ? signal.reason : 'command aborted') +} + +/** Stop awaiting an uncooperative handler once its owning UI request aborts. */ +function withAbort(promise: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(abortError(signal)) + return new Promise((resolve, reject) => { + const onAbort = (): void => { + signal.removeEventListener('abort', onAbort) + reject(abortError(signal)) + } + signal.addEventListener('abort', onAbort, { once: true }) + promise.then( + (value) => { + signal.removeEventListener('abort', onAbort) + resolve(value) + }, + (error: unknown) => { + signal.removeEventListener('abort', onAbort) + reject(error instanceof Error + ? error + : new Error('command handler rejected with a non-Error value')) + }, + ) + }) +} + +/** Reject invalid command metadata before it can reach a UI protocol. */ +function normalizeDefinition(definition: CommandDefinition): RegisteredCommand { + if (!COMMAND_NAME.test(definition.name)) { + throw new TypeError(`command name "${definition.name}" must match ${String(COMMAND_NAME)}`) + } + if (definition.description.trim().length === 0) { + throw new TypeError(`command "${definition.name}" description must not be empty`) + } + if (typeof definition.handler !== 'function') { + throw new TypeError(`command "${definition.name}" handler must be a function`) + } + const input = definition.input === undefined + ? undefined + : Object.freeze({ hint: definition.input.hint }) + if (input !== undefined && input.hint.trim().length === 0) { + throw new TypeError(`command "${definition.name}" input hint must not be empty`) + } + const surfaces = [...(definition.surfaces ?? DEFAULT_SURFACES)] + if (surfaces.length === 0) { + throw new TypeError(`command "${definition.name}" must expose at least one surface`) + } + const unique = new Set() + for (const surface of surfaces) { + if (!SURFACE_NAME.test(surface)) { + throw new TypeError(`command "${definition.name}" surface "${surface}" must match ${String(SURFACE_NAME)}`) + } + if (unique.has(surface)) { + throw new TypeError(`command "${definition.name}" surface "${surface}" is duplicated`) + } + unique.add(surface) + } + const frozenSurfaces = Object.freeze(surfaces) + const normalized = Object.freeze({ + name: definition.name, + description: definition.description, + ...input === undefined ? {} : { input }, + surfaces: frozenSurfaces, + handler: definition.handler, + }) + const descriptor = Object.freeze({ + name: normalized.name, + description: normalized.description, + ...normalized.input === undefined ? {} : { input: normalized.input }, + surfaces: normalized.surfaces, + }) + return { definition: normalized, descriptor } +} + +/** Validate and detach an untrusted handler result at the registry boundary. */ +function normalizeResult(command: string, value: unknown): CommandResult { + if (typeof value !== 'object' || value === null || !('kind' in value)) { + throw new TypeError(`command "${command}" handler must return a CommandResult`) + } + const result = value as { kind?: unknown; text?: unknown } + if (result.kind === 'success') { + if (result.text !== undefined && typeof result.text !== 'string') { + throw new TypeError(`command "${command}" success text must be a string when supplied`) + } + return Object.freeze(result.text === undefined ? { kind: 'success' } : { kind: 'success', text: result.text }) + } + if (result.kind === 'error') { + if (typeof result.text !== 'string' || result.text.trim().length === 0) { + throw new TypeError(`command "${command}" error text must be a non-empty string`) + } + return Object.freeze({ kind: 'error', text: result.text }) + } + throw new TypeError(`command "${command}" returned unknown result kind "${String(result.kind)}"`) +} + +/** + * Human-command registry. Plain-context definitions are global; definitions + * registered through a command-injected child of an agent context shadow + * globals for that agent. + */ +export class CommandService extends Service { + private readonly global = new Map() + private readonly scoped = new Map>() + + constructor(ctx: Context) { + super(ctx, 'commands') + } + + /** + * Register a global or calling-agent-scoped command. + * @param definition - discovery metadata, surface mask, and direct UI handler. + * @returns the exact effect disposer that unregisters this definition. + */ + register(definition: CommandDefinition): () => void { + const scope = scopeOf(this.ctx) + const registered = normalizeDefinition(definition) + const dispose = this.ctx.effect(function* (this: CommandService) { + const layer = scope === undefined ? this.global : this.layerFor(scope) + if (layer.has(registered.definition.name)) { + throw new Error(scope === undefined + ? `command "${registered.definition.name}" is already registered (for a per-agent variant, mount a command-injected plugin under that agent's \`agent.ctx\`)` + : `command "${registered.definition.name}" is already registered in this scope`) + } + layer.set(registered.definition.name, registered) + yield () => { + layer.delete(registered.definition.name) + if (scope !== undefined && layer.size === 0) this.scoped.delete(scope) + this.ctx.emit('commands/change') + } + this.ctx.emit('commands/change') + }.bind(this), 'commands.register()') + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- exact synchronous disposer preserves composite teardown order + return dispose + } + + /** + * List the effective immutable command descriptors for one agent and surface. + * @param agent - exact receiving agent and scoped-layer key. + * @param surface - UI adapter requesting discovery metadata. + * @returns name-sorted descriptors after scoped shadowing and surface filtering. + */ + list(agent: Agent, surface: CommandSurface): readonly CommandDescriptor[] { + return Object.freeze([...this.view(agent).values()] + .filter(command => command.definition.surfaces.includes(surface)) + .map(command => command.descriptor) + // Names are unique in the effective view, so equality is impossible. + .sort((left, right) => left.name < right.name ? -1 : 1)) + } + + /** + * Resolve one effective command definition. + * @param agent - exact receiving agent and scoped-layer key. + * @param surface - UI adapter performing the lookup. + * @param name - command name without a slash. + * @returns the scoped shadow or global definition when visible on the surface. + */ + find(agent: Agent, surface: CommandSurface, name: string): CommandDefinition | undefined { + const command = this.view(agent).get(name) + return command?.definition.surfaces.includes(surface) === true ? command.definition : undefined + } + + /** + * Parse and execute a known command without sending it to the model. + * @param agent - exact receiving agent. + * @param surface - dispatching UI adapter. + * @param line - complete slash-command line. + * @param signal - cancellation signal owned by the UI request. + * @returns a detached result, or `undefined` when syntax/name/surface does not resolve. + */ + async execute( + agent: Agent, + surface: CommandSurface, + line: string, + signal: AbortSignal, + ): Promise { + const parsed = parseCommand(line) + if (parsed === undefined) return undefined + const command = this.view(agent).get(parsed.name) + if (command === undefined || !command.definition.surfaces.includes(surface)) return undefined + if (signal.aborted) throw abortError(signal) + const invocation = Object.freeze({ agent, surface, rawInput: parsed.rawInput, signal }) + const output = command.definition.handler(invocation) + return normalizeResult(parsed.name, await withAbort(Promise.resolve(output), signal)) + } + + /** Resolve global definitions followed by exact scoped shadows. */ + private view(agent: Agent): Map { + const visible = new Map(this.global) + for (const [name, command] of this.scoped.get(agent) ?? []) visible.set(name, command) + return visible + } + + /** Create the registration layer for one agent scope on demand. */ + private layerFor(scope: ScopeKey): Map { + let layer = this.scoped.get(scope) + if (layer === undefined) { + layer = new Map() + this.scoped.set(scope, layer) + } + return layer + } +} + +export default CommandService diff --git a/packages/ui/commands/tests/commands.spec.ts b/packages/ui/commands/tests/commands.spec.ts new file mode 100644 index 0000000000..67f9d3d0e2 --- /dev/null +++ b/packages/ui/commands/tests/commands.spec.ts @@ -0,0 +1,262 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { createScope } from '@deepseek-ai/dsh-scope' +import type { Scope } from '@deepseek-ai/dsh-scope' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { SessionId } from '@deepseek-ai/dsh-session' +import CommandService, { parseCommand, type CommandDefinition } from '@deepseek-ai/dsh-commands' + +function command(name: string, text = `ran:${name}`): CommandDefinition { + return { + name, + description: `command ${name}`, + handler: () => ({ kind: 'success', text }), + } +} + +async function mount(): Promise { + const ctx = new Context() + await ctx.plugin(CommandService) + return ctx +} + +/** Mint a scope whose key is sufficient for registry lookup and invocation. */ +async function mintAgentScope(ctx: Context, name: string): Promise<{ scope: Scope; agent: Agent }> { + const agent = { id: name as SessionId } as Agent + let scope!: Scope + await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, { inject: ['commands'] })) + return { scope, agent } +} + +describe('parseCommand()', () => { + it.each([ + ['/goal', { name: 'goal', rawInput: '' }], + ['/goal create the thing', { name: 'goal', rawInput: ' create the thing' }], + ['/goal\ncreate the thing', { name: 'goal', rawInput: '\ncreate the thing' }], + ['/goal_name-2\t x ', { name: 'goal_name-2', rawInput: '\t x ' }], + ] as const)('parses %j without normalizing trailing input', (line, expected) => { + expect(parseCommand(line)).toEqual(expected) + }) + + it.each(['goal', ' /goal', '/', '/Goal', '/goal/path', '/goal🔥'])('rejects non-command boundary %j', (line) => { + expect(parseCommand(line)).toBeUndefined() + }) +}) + +describe('CommandService', () => { + it('lists immutable global descriptors with default surfaces and ACP input metadata', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + const definition: CommandDefinition = { + name: 'inspect', + description: 'Inspect state', + input: { hint: '' }, + handler: () => ({ kind: 'success' }), + } + ctx.commands.register(definition) + + const listed = ctx.commands.list(agent, 'acp') + expect(listed).toEqual([{ + name: 'inspect', + description: 'Inspect state', + input: { hint: '' }, + surfaces: ['tui', 'acp'], + }]) + expect(Object.isFrozen(listed)).toBe(true) + expect(Object.isFrozen(listed[0])).toBe(true) + expect(Object.isFrozen(listed[0]?.input)).toBe(true) + expect(Object.isFrozen(listed[0]?.surfaces)).toBe(true) + expect(ctx.commands.find(agent, 'tui', 'inspect')).toMatchObject({ name: 'inspect' }) + expect(ctx.commands.find(agent, 'other', 'inspect')).toBeUndefined() + expect(ctx.commands.find(agent, 'tui', 'missing')).toBeUndefined() + }) + + it('sorts distinct effective command names', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + ctx.commands.register(command('zeta')) + ctx.commands.register(command('alpha')) + ctx.commands.register(command('middle')) + expect(ctx.commands.list(agent, 'tui').map(item => item.name)).toEqual(['alpha', 'middle', 'zeta']) + }) + + it('uses agent-scoped shadows and removes them with their scope', async () => { + const ctx = await mount() + const { scope, agent } = await mintAgentScope(ctx, 'a') + const other = { id: 'other' as SessionId } as Agent + ctx.commands.register(command('shared', 'global')) + scope.ctx.commands.register({ ...command('shared', 'scoped'), surfaces: ['tui'] }) + + expect(ctx.commands.list(agent, 'tui').map(item => item.name)).toEqual(['shared']) + expect(ctx.commands.list(agent, 'acp')).toEqual([]) + expect(ctx.commands.find(agent, 'tui', 'shared')?.handler).toBeDefined() + expect(ctx.commands.list(other, 'acp').map(item => item.name)).toEqual(['shared']) + expect(await ctx.commands.execute(agent, 'tui', '/shared', new AbortController().signal)) + .toEqual({ kind: 'success', text: 'scoped' }) + + await scope.dispose() + expect((await ctx.commands.execute(agent, 'tui', '/shared', new AbortController().signal))?.text).toBe('global') + }) + + it('rejects duplicates within one layer while allowing a scoped shadow', async () => { + const ctx = await mount() + const { scope } = await mintAgentScope(ctx, 'a') + ctx.commands.register(command('same')) + expect(() => ctx.commands.register(command('same'))).toThrow(/agent\.ctx/) + scope.ctx.commands.register(command('same')) + expect(() => scope.ctx.commands.register(command('same'))).toThrow(/already registered in this scope/) + }) + + it('emits on registration and disposal and rolls back when notification fails', async () => { + const ctx = await mount() + const changed = vi.fn() + ctx.on('commands/change', changed) + const dispose = ctx.commands.register(command('live')) + dispose() + dispose() + expect(changed).toHaveBeenCalledTimes(2) + + const explode = ctx.on('commands/change', () => { throw new Error('observer failed') }) + expect(() => ctx.commands.register(command('rollback'))).toThrow('observer failed') + explode() + const { agent } = await mintAgentScope(ctx, 'a') + expect(ctx.commands.find(agent, 'tui', 'rollback')).toBeUndefined() + }) + + it('passes exact invocation context and detaches valid handler results', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + const seen = vi.fn(() => ({ kind: 'success' as const, text: 'ok' })) + ctx.commands.register({ name: 'run', description: 'Run it', surfaces: ['acp'], handler: seen }) + const controller = new AbortController() + + const result = await ctx.commands.execute(agent, 'acp', '/run untouched ', controller.signal) + + expect(result).toEqual({ kind: 'success', text: 'ok' }) + expect(Object.isFrozen(result)).toBe(true) + expect(seen).toHaveBeenCalledWith(expect.objectContaining({ + agent, + surface: 'acp', + rawInput: ' untouched ', + signal: controller.signal, + })) + await expect(ctx.commands.execute(agent, 'tui', '/run', controller.signal)).resolves.toBeUndefined() + await expect(ctx.commands.execute(agent, 'acp', 'run', controller.signal)).resolves.toBeUndefined() + await expect(ctx.commands.execute(agent, 'acp', '/missing', controller.signal)).resolves.toBeUndefined() + }) + + it('stops awaiting an aborted handler and handles an already-aborted signal', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + let release!: (result: { kind: 'success'; text: string }) => void + ctx.commands.register({ + name: 'wait', + description: 'Wait', + handler: () => new Promise((resolve) => { release = resolve }), + }) + const running = new AbortController() + const promise = ctx.commands.execute(agent, 'tui', '/wait', running.signal) + running.abort('operator cancelled command') + await expect(promise).rejects.toThrow('operator cancelled command') + release({ kind: 'success', text: 'late' }) + + const already = new AbortController() + already.abort(new Error('already gone')) + await expect(ctx.commands.execute(agent, 'tui', '/wait', already.signal)).rejects.toThrow('already gone') + + const defaultReason = new AbortController() + defaultReason.abort({ source: 'test' }) + await expect(ctx.commands.execute(agent, 'tui', '/wait', defaultReason.signal)).rejects.toThrow('command aborted') + }) + + it('propagates an asynchronously rejected handler', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + ctx.commands.register({ + name: 'reject', + description: 'Reject', + handler: () => Promise.reject(new Error('handler rejected')), + }) + await expect(ctx.commands.execute(agent, 'tui', '/reject', new AbortController().signal)) + .rejects.toThrow('handler rejected') + + ctx.commands.register({ + name: 'reject-value', + description: 'Reject a non-Error value', + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise untyped plugin normalization + handler: () => Promise.reject('not an Error'), + }) + await expect(ctx.commands.execute(agent, 'tui', '/reject-value', new AbortController().signal)) + .rejects.toThrow('command handler rejected with a non-Error value') + }) + + it('observes an abort triggered synchronously inside the handler', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + const controller = new AbortController() + ctx.commands.register({ + name: 'self-abort', + description: 'Abort before returning', + handler: () => { + controller.abort('aborted in handler') + return { kind: 'success' } + }, + }) + await expect(ctx.commands.execute(agent, 'tui', '/self-abort', controller.signal)) + .rejects.toThrow('aborted in handler') + }) + + it('returns a detached expected-error result', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + ctx.commands.register({ + name: 'denied', + description: 'Denied', + handler: () => ({ kind: 'error', text: 'not now' }), + }) + const result = await ctx.commands.execute(agent, 'tui', '/denied', new AbortController().signal) + expect(result).toEqual({ kind: 'error', text: 'not now' }) + expect(Object.isFrozen(result)).toBe(true) + + ctx.commands.register({ + name: 'silent', + description: 'No output', + handler: () => ({ kind: 'success' }), + }) + const silent = await ctx.commands.execute(agent, 'tui', '/silent', new AbortController().signal) + expect(silent).toEqual({ kind: 'success' }) + expect(Object.isFrozen(silent)).toBe(true) + }) + + it.each([ + [{ ...command('Bad') }, /command name/], + [{ ...command('empty-description'), description: ' ' }, /description/], + [{ ...command('empty-hint'), input: { hint: '' } }, /input hint/], + [{ ...command('no-surface'), surfaces: [] }, /at least one surface/], + [{ ...command('bad-surface'), surfaces: ['ACP'] }, /surface/], + [{ ...command('duplicate-surface'), surfaces: ['tui', 'tui'] }, /duplicated/], + [{ ...command('bad-handler'), handler: undefined }, /handler/], + ] as const)('rejects invalid definition %#', async (definition, expected) => { + const ctx = await mount() + expect(() => ctx.commands.register(definition as unknown as CommandDefinition)).toThrow(expected) + }) + + it.each([ + [undefined, /CommandResult/], + [null, /CommandResult/], + [{}, /CommandResult/], + [{ kind: 'success', text: 1 }, /success text/], + [{ kind: 'error', text: '' }, /error text/], + [{ kind: 'error', text: 1 }, /error text/], + [{ kind: 'future', text: 'x' }, /unknown result kind/], + ] as const)('rejects malformed handler result %j', async (output, expected) => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + ctx.commands.register({ + name: 'broken', + description: 'Broken', + handler: () => output as never, + }) + await expect(ctx.commands.execute(agent, 'tui', '/broken', new AbortController().signal)).rejects.toThrow(expected) + }) +}) diff --git a/packages/ui/commands/tsconfig.json b/packages/ui/commands/tsconfig.json new file mode 100644 index 0000000000..478cc26479 --- /dev/null +++ b/packages/ui/commands/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/scope" + } + ] +} diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index e426640ae5..9fad307802 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -4,13 +4,13 @@ The interactive terminal front door for DeepSeek Harness agents, built on [`@ear The implemented [TUI feature RFC](../../../docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) owns the front-door decision; the [terminal-state snapshot RFC](../../../docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns its verification strategy. -This package owns interactive terminal presentation and input only. It injects `agents`, `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries. +This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries. The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions as keyboard-driven overlays. Surface replacement events rebuild the transcript so compacted history does not reappear. Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling. -While the agent is running, editor submissions call `agent.steer()`; otherwise they call `agent.send()`. Ctrl+C or Escape cancels a running turn. Ctrl+O expands tool cards, Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/help`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` provide the same actions without key chords. +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 reaches the model. The TUI registers `/help`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` as agent-scoped definitions; plugin commands for the `tui` surface join autocomplete and `/help` dynamically. Ctrl+C or Escape cancels a running turn. Ctrl+O expands tool cards, Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. ## Config @@ -37,7 +37,7 @@ While the agent is running, editor submissions call `agent.steer()`; otherwise t maxToolOutputLines: 12 ``` -Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR. +Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal aborts running commands, removes the TUI definitions, stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR. ## Color @@ -47,7 +47,7 @@ The palette uses the standard 16-color ANSI foregrounds and SGR attributes, whic ### Interactive prompt input -**What the model sees**: Each non-empty editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. Slash commands and keybindings are TUI-only. +**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. Slash commands and keybindings are TUI-only; command results remain terminal notices. **Token effect**: Submitted text is retained under the agent loop's normal session-history and compaction rules. Headers, cards, Markdown rendering, status lines, plans, and help text add no tokens. diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json index fd4f187e35..d470afeaf2 100644 --- a/packages/ui/tui/package.json +++ b/packages/ui/tui/package.json @@ -24,6 +24,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-loop": "^0.0.1", + "@deepseek-ai/dsh-commands": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", @@ -38,6 +39,7 @@ "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 1c3fc1315c..2ace80165c 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -35,6 +35,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-loop' +import type {} from '@deepseek-ai/dsh-commands' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionId, type Session, type SessionEvent, type TodoItem } from '@deepseek-ai/dsh-session' import type { @@ -53,7 +54,7 @@ import { } from '@deepseek-ai/dsh-user-interaction' export const name = 'ui-tui' -export const inject = ['agents', 'userInteraction', 'tools'] +export const inject = ['agents', 'commands', 'userInteraction', 'tools'] /** Presentation settings for the pi-tui terminal mode. */ export interface TuiConfig { @@ -839,6 +840,7 @@ export function createTuiChat( const allToolCards = new Set() const liveErrors = new Set() const questionQueue: PendingQuestion[] = [] + const commandControllers = new Set() let activeQuestion: PendingQuestion | undefined const welcome = config.welcome ?? 'ready.' @@ -1097,6 +1099,8 @@ export function createTuiChat( shuttingDown ??= (async () => { disposed = true clearStatus() + for (const controller of commandControllers) controller.abort(new Error('TUI disposed')) + commandControllers.clear() if (activeQuestion !== undefined) { const pending = activeQuestion activeQuestion = undefined @@ -1121,16 +1125,6 @@ export function createTuiChat( void shutdown(true) } - editor.setAutocompleteProvider(new CombinedAutocompleteProvider([ - { name: 'help', description: 'Show keyboard shortcuts and commands' }, - { name: 'clear', description: 'Clear the transcript view (session history is unchanged)' }, - { name: 'cancel', description: 'Cancel the active turn' }, - { name: 'reasoning', description: 'Toggle reasoning blocks' }, - { name: 'tools', description: 'Expand or collapse all tool cards' }, - { name: 'redraw', description: 'Invalidate components and redraw the terminal' }, - { name: 'exit', description: 'Exit after the active turn reaches idle' }, - ], agent.session.header.cwd ?? process.cwd())) - const toggleTools = (): void => { toolsExpanded = !toolsExpanded for (const card of allToolCards) card.setExpanded(toolsExpanded) @@ -1150,52 +1144,113 @@ export function createTuiChat( } const showHelp = (): void => { + const commandLines = ctx.commands.list(agent, 'tui').map((command) => { + const input = command.input === undefined ? '' : ` ${command.input.hint}` + return `/${command.name}${input} — ${command.description}` + }) chat.addChild(new Spacer(1)) chat.addChild(new Text(palette.bold(palette.accent('Keyboard shortcuts')), 1, 0)) chat.addChild(new Text([ 'Enter send • Shift/Alt+Enter newline • Up/Down prompt history', 'Esc cancel active turn • Ctrl+O expand tool cards • Ctrl+R toggle reasoning', 'Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit', - '/help /clear /cancel /reasoning /tools /redraw /exit', + '', + ...commandLines, ].map(line => palette.muted(line)).join('\n'), 1, 0)) requestRender() } + const refreshCommandAutocomplete = (): void => { + editor.setAutocompleteProvider(new CombinedAutocompleteProvider( + ctx.commands.list(agent, 'tui').map(command => ({ + name: command.name, + description: command.description, + })), + agent.session.header.cwd ?? process.cwd(), + )) + } + const disposeCommandChanges = ctx.on('commands/change', refreshCommandAutocomplete) + refreshCommandAutocomplete() + + // The agent scope is minted by agent-loop and intentionally inherits only + // that core plugin's dependencies. A child command producer declares its own + // UI-service dependency while retaining the parent agent scope and lifetime. + const commandFiber = agent.ctx.inject(['commands'], (commandCtx) => { + commandCtx.commands.register({ + name: 'help', + description: 'Show keyboard shortcuts and commands', + surfaces: ['tui'], + handler: () => { showHelp(); return { kind: 'success' } }, + }) + commandCtx.commands.register({ + name: 'clear', + description: 'Clear the transcript view (session history is unchanged)', + surfaces: ['tui'], + handler: () => { chat.clear(); requestRender(); return { kind: 'success' } }, + }) + commandCtx.commands.register({ + name: 'cancel', + description: 'Cancel the active turn', + surfaces: ['tui'], + handler: () => { + if (agent.status !== 'running') return { kind: 'error', text: 'The agent is already idle.' } + agent.cancel('cancelled from terminal') + return { kind: 'success', text: 'Cancellation requested.' } + }, + }) + commandCtx.commands.register({ + name: 'reasoning', + description: 'Toggle reasoning blocks', + surfaces: ['tui'], + handler: () => { toggleReasoning(); return { kind: 'success' } }, + }) + commandCtx.commands.register({ + name: 'tools', + description: 'Expand or collapse all tool cards', + surfaces: ['tui'], + handler: () => { toggleTools(); return { kind: 'success' } }, + }) + commandCtx.commands.register({ + name: 'redraw', + description: 'Invalidate components and redraw the terminal', + surfaces: ['tui'], + handler: () => { ui.invalidate(); ui.requestRender(true); return { kind: 'success' } }, + }) + commandCtx.commands.register({ + name: 'exit', + description: 'Exit after the active turn reaches idle', + surfaces: ['tui'], + handler: () => { requestExit(); return { kind: 'success' } }, + }) + }) + + const runCommand = (text: string): void => { + const controller = new AbortController() + commandControllers.add(controller) + void ctx.commands.execute(agent, 'tui', text, controller.signal).then( + (result) => { + if (result === undefined) { + appendNotice(`Unknown command: ${text}`, 'warning') + } else if (result.text !== undefined && result.text !== '') { + appendNotice(result.text, result.kind === 'error' ? 'error' : 'info') + } + }, + (error: unknown) => { + if (!disposed) { + appendNotice(`Command failed: ${renderThrown(error)}`, 'error') + } + }, + ).finally(() => { commandControllers.delete(controller) }) + } + editor.onSubmit = (value: string) => { const text = value.trim() if (text === '') return editor.addToHistory(text) editor.setText('') - switch (text) { - case '/help': - showHelp() - return - case '/clear': - chat.clear() - requestRender() - return - case '/cancel': - if (agent.status === 'running') agent.cancel('cancelled from terminal') - else appendNotice('The agent is already idle.') - return - case '/reasoning': - toggleReasoning() - return - case '/tools': - toggleTools() - return - case '/redraw': - ui.invalidate() - ui.requestRender(true) - return - case '/exit': - requestExit() - return - default: - if (text.startsWith('/')) { - appendNotice(`Unknown command: ${text}`, 'warning') - return - } + if (value.startsWith('/')) { + runCommand(value) + return } if (agent.status === 'disposed') { appendNotice(`Agent "${agent.id}" is disposed.`, 'error') @@ -1273,6 +1328,7 @@ export function createTuiChat( const detachListeners = (): void => { removeInputListener() + disposeCommandChanges() disposeSessionEvents() disposeStatus() disposeError() @@ -1286,6 +1342,7 @@ export function createTuiChat( } catch (error: unknown) { disposed = true detachListeners() + void commandFiber.dispose() clearStatus() disposeUserInteraction() ui.stop() @@ -1296,6 +1353,7 @@ export function createTuiChat( async dispose(): Promise { detachListeners() await shutdown(false) + await commandFiber.dispose() }, } } diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 9994833308..9d0520503b 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -1,6 +1,7 @@ import { Context } from 'cordis' import type { Terminal } from '@earendil-works/pi-tui' import AgentRegistry, { type Agent, type AgentStatus } from '@deepseek-ai/dsh-agent' +import CommandService from '@deepseek-ai/dsh-commands' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' @@ -47,6 +48,7 @@ export async function createTuiTestHarness { const unwrapped = loader.unwrapExports(tui) as Record expect(unwrapped).toBe(tui) expect(unwrapped.name).toBe('ui-tui') - expect(unwrapped.inject).toEqual(['agents', 'userInteraction', 'tools']) + expect(unwrapped.inject).toEqual(['agents', 'commands', 'userInteraction', 'tools']) expect(unwrapped.Config).toBeDefined() expect(typeof unwrapped.apply).toBe('function') }) diff --git a/packages/ui/tui/tests/snapshots/disposed-terminal.golden.txt b/packages/ui/tui/tests/snapshots/disposed-terminal.golden.txt index 05809dea0c..5006083358 100644 --- a/packages/ui/tui/tests/snapshots/disposed-terminal.golden.txt +++ b/packages/ui/tui/tests/snapshots/disposed-terminal.golden.txt @@ -1,7 +1,7 @@ terminal 92x32 buffer=normal length=32 base=0 viewport=0 lifecycle started=1 stopped=1 progress=inactive title "DSH snapshot" -cursor visible column=0 viewportRow=22 bufferRow=22 +cursor visible column=0 viewportRow=29 bufferRow=29 buffer 0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮" style 0-91 fg=bright-blue @@ -29,24 +29,37 @@ buffer style 1-75 fg=bright-black 9| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit " style 1-73 fg=bright-black -10| " /help /clear /cancel /reasoning /tools /redraw /exit " - style 1-52 fg=bright-black -11| -12| " Unknown command: /unknown-advanced-command " - style 1-42 fg=yellow -13| -14| " provider stream failed after partial output " +10| " " +11| " /cancel — Cancel the active turn " + style 1-32 fg=bright-black +12| " /clear — Clear the transcript view (session history is unchanged) " + style 1-65 fg=bright-black +13| " /exit — Exit after the active turn reaches idle " + style 1-47 fg=bright-black +14| " /help — Show keyboard shortcuts and commands " + style 1-44 fg=bright-black +15| " /reasoning — Toggle reasoning blocks " + style 1-36 fg=bright-black +16| " /redraw — Invalidate components and redraw the terminal " + style 1-55 fg=bright-black +17| " /tools — Expand or collapse all tool cards " + style 1-42 fg=bright-black +18| +19| " provider stream failed after partial output " style 1-43 fg=red -15| -16| " The previous process ended during this turn. " +20| +21| " The previous process ended during this turn. " style 1-44 fg=yellow -17| "────────────────────────────────────────────────────────────────────────────────────────────" +22| +23| " Unknown command: /unknown-advanced-command " + style 1-42 fg=yellow +24| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -18| " " +25| " " style 1-1 inverse -19| "────────────────────────────────────────────────────────────────────────────────────────────" +26| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -20| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +27| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" style 0-24 dim style 59-91 dim -21-31| +28-31| diff --git a/packages/ui/tui/tests/snapshots/errors-and-help.golden.txt b/packages/ui/tui/tests/snapshots/errors-and-help.golden.txt index fccfca604b..4a99d7e9e7 100644 --- a/packages/ui/tui/tests/snapshots/errors-and-help.golden.txt +++ b/packages/ui/tui/tests/snapshots/errors-and-help.golden.txt @@ -1,7 +1,7 @@ terminal 92x32 buffer=normal length=32 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=18 bufferRow=18 +cursor hidden column=1 viewportRow=25 bufferRow=25 buffer 0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮" style 0-91 fg=bright-blue @@ -29,24 +29,37 @@ buffer style 1-75 fg=bright-black 9| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit " style 1-73 fg=bright-black -10| " /help /clear /cancel /reasoning /tools /redraw /exit " - style 1-52 fg=bright-black -11| -12| " Unknown command: /unknown-advanced-command " - style 1-42 fg=yellow -13| -14| " provider stream failed after partial output " +10| " " +11| " /cancel — Cancel the active turn " + style 1-32 fg=bright-black +12| " /clear — Clear the transcript view (session history is unchanged) " + style 1-65 fg=bright-black +13| " /exit — Exit after the active turn reaches idle " + style 1-47 fg=bright-black +14| " /help — Show keyboard shortcuts and commands " + style 1-44 fg=bright-black +15| " /reasoning — Toggle reasoning blocks " + style 1-36 fg=bright-black +16| " /redraw — Invalidate components and redraw the terminal " + style 1-55 fg=bright-black +17| " /tools — Expand or collapse all tool cards " + style 1-42 fg=bright-black +18| +19| " provider stream failed after partial output " style 1-43 fg=red -15| -16| " The previous process ended during this turn. " +20| +21| " The previous process ended during this turn. " style 1-44 fg=yellow -17| "────────────────────────────────────────────────────────────────────────────────────────────" +22| +23| " Unknown command: /unknown-advanced-command " + style 1-42 fg=yellow +24| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -18| " " +25| " " style 1-1 inverse -19| "────────────────────────────────────────────────────────────────────────────────────────────" +26| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -20| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +27| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" style 0-24 dim style 59-91 dim -21-31| +28-31| diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 27200e0fa9..bf63aeaeb9 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { Terminal } from '@earendil-works/pi-tui' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -431,6 +432,84 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(disposedAgent) }) + it('discovers and executes plugin commands, then removes TUI-local commands on disposal', async () => { + const result = await setup() + const handler = vi.fn(({ rawInput }: CommandInvocation) => ({ + kind: 'success' as const, + text: `PLUGIN:${rawInput}`, + })) + result.ctx.commands.register({ + name: 'plugin-check', + description: 'Run a plugin command', + input: { hint: '' }, + surfaces: ['tui'], + handler, + }) + result.ctx.commands.register({ + name: 'plugin-fail', + description: 'Fail a plugin command', + surfaces: ['tui'], + handler: () => { throw new Error('plugin command exploded') }, + }) + + result.terminal.send('/plugin-check value ') + result.terminal.send('\r') + await tick() + + expect(handler).toHaveBeenCalledTimes(1) + const invocation = handler.mock.calls[0]?.[0] + expect(invocation?.agent).toBe(result.agent) + expect(invocation?.surface).toBe('tui') + // pi-tui's Editor owns terminal-line normalization and removes trailing + // spaces before onSubmit; the registry preserves the adapter-delivered line. + expect(invocation?.rawInput).toBe(' value') + expect(result.terminal.output).toContain('PLUGIN: value') + result.terminal.send('/plugin-fail') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Command failed: Error: plugin command exploded') + result.terminal.send('/help') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('/plugin-check — Run a plugin command') + expect(result.ctx.commands.list(result.agent, 'tui').map(command => command.name)).toContain('help') + + await result.controller.dispose() + expect(result.ctx.commands.list(result.agent, 'tui').map(command => command.name)).toEqual([ + 'plugin-check', + 'plugin-fail', + ]) + await result.ctx.fiber.dispose() + }) + + it('aborts an in-flight plugin command during TUI disposal', async () => { + const result = await setup() + let started!: () => void + const ready = new Promise((resolve) => { started = resolve }) + let commandSignal: AbortSignal | undefined + result.ctx.commands.register({ + name: 'wait-plugin', + description: 'Wait until disposal', + surfaces: ['tui'], + handler: ({ signal }) => { + commandSignal = signal + started() + return new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve({ kind: 'error', text: 'late result' }) }, { once: true }) + }) + }, + }) + + result.terminal.send('/wait-plugin') + result.terminal.send('\r') + await ready + await result.controller.dispose() + + expect(commandSignal?.aborted).toBe(true) + expect(result.terminal.output).not.toContain('late result') + await result.ctx.fiber.dispose() + }) + it('cancels before /exit while running and handles agent errors/disposal', async () => { const result = await setup({ status: 'running' }) result.terminal.send('/exit') @@ -808,6 +887,7 @@ describe('terminal mounting', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) + await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) ctx.provide('tools', { get: () => undefined } as never) const session = ctx.sessions.create(SessionId('main')) @@ -826,6 +906,7 @@ describe('terminal mounting', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) + await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) ctx.provide('tools', { get: () => undefined } as never) const terminal = new FakeTerminal() @@ -854,6 +935,7 @@ describe('terminal mounting', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) + await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) ctx.provide('tools', { get: () => undefined } as never) const terminal = new FakeTerminal() @@ -881,6 +963,7 @@ describe('terminal mounting', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) + await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) ctx.provide('tools', { get: () => undefined } as never) const terminal = new FakeTerminal() @@ -901,6 +984,7 @@ describe('terminal mounting', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) + await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) ctx.provide('tools', { get: () => undefined } as never) const session = ctx.sessions.create(SessionId('failed-start-session')) @@ -913,6 +997,8 @@ describe('terminal mounting', () => { expect(() => createTuiChat(ctx, { sessionId: 'failed-start-session', color: false }, { terminal, exit: vi.fn() })) .toThrow('terminal startup failed') + await tick() + expect(ctx.commands.list(ctx.agents.get(SessionId('failed-start-session'))!, 'tui')).toEqual([]) expect(terminal.stopped).toBe(1) expect(terminal.progress).toEqual([false, true, false]) await expect(ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Late?' }] })) @@ -930,6 +1016,7 @@ describe('terminal mounting', () => { it('throws when createTuiChat is called without the configured agent', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) + await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) ctx.provide('tools', { get: () => undefined } as never) const runtime: TuiRuntime = { terminal: new FakeTerminal(), exit: vi.fn() } diff --git a/packages/ui/tui/tsconfig.json b/packages/ui/tui/tsconfig.json index 3a09f80ad8..83ea611409 100644 --- a/packages/ui/tui/tsconfig.json +++ b/packages/ui/tui/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../core/tools" }, + { + "path": "../commands" + }, { "path": "../user-interaction" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 728fa9ac33..28469ff9c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -667,6 +667,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../ui/app-boot + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../ui/commands '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl @@ -825,6 +828,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../ui/app-boot + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../ui/commands '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -2018,6 +2024,9 @@ importers: '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../bash/bash-local + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../commands '@deepseek-ai/dsh-fs-local': specifier: workspace:^ version: link:../../fs/fs-local @@ -2085,6 +2094,21 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + packages/ui/commands: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/ui/jsonrpc: dependencies: schemastery: @@ -2211,6 +2235,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../commands '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -2565,6 +2592,9 @@ importers: '@deepseek-ai/dsh-code-runtime-worker': specifier: workspace:^ version: link:../../packages/code-runtime/code-runtime-worker + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../packages/ui/commands '@deepseek-ai/dsh-compact': specifier: workspace:^ version: link:../../packages/compact/compact diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 2d3560ec5c..161c5c3460 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -18,6 +18,7 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-code-runtime": "workspace:^", "@deepseek-ai/dsh-code-runtime-worker": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-compact-basic": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 289a2e9778..73f533e34b 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -74,6 +74,10 @@ export const LINK_MAP: Record = { GoalChanged: 'goal.md', GoalRef: 'goal.md', GoalView: 'goal.md', + CommandDefinition: 'commands.md', + CommandDescriptor: 'commands.md', + CommandResult: 'commands.md', + CommandSurface: 'commands.md', LlmAdapter: 'llm-streaming.md', LlmService: 'llm-streaming.md', StreamChunk: 'llm-streaming.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index bd0ecc18df..07f8d6315d 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -145,6 +145,14 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['tool-ask-user', 'stdio-demo', 'acp'], note: 'UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.', }, + { + key: 'commands', + pkg: 'commands', + title: 'Human command registry', + mode: 'core', + consumers: ['tui', 'acp'], + note: 'Plugins register direct human commands; TUI and ACP resolve each agent and surface without sending the invocation to the model.', + }, { key: 'skills', pkg: 'skill', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 1092c6c0b0..867ee39bd4 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -39,6 +39,14 @@ { "doc": "docs/core-data-structures/goal.md", "symbol": "EditGoalRequest", "source": "packages/goal/goal/src/types.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalChanged", "source": "packages/goal/goal/src/types.ts" }, + { "doc": "docs/core-data-structures/commands.md", "symbol": "CommandSurface", "source": "packages/ui/commands/src/index.ts" }, + { "doc": "docs/core-data-structures/commands.md", "symbol": "CommandInputDescriptor", "source": "packages/ui/commands/src/index.ts" }, + { "doc": "docs/core-data-structures/commands.md", "symbol": "CommandDefinition", "source": "packages/ui/commands/src/index.ts" }, + { "doc": "docs/core-data-structures/commands.md", "symbol": "CommandInvocation", "source": "packages/ui/commands/src/index.ts" }, + { "doc": "docs/core-data-structures/commands.md", "symbol": "CommandResult", "source": "packages/ui/commands/src/index.ts" }, + { "doc": "docs/core-data-structures/commands.md", "symbol": "CommandDescriptor", "source": "packages/ui/commands/src/index.ts" }, + { "doc": "docs/core-data-structures/commands.md", "symbol": "ParsedCommand", "source": "packages/ui/commands/src/index.ts" }, + { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "AssembleContext", "source": "packages/core/system-prompt/src/index.ts" }, { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "PromptSection", "source": "packages/core/system-prompt/src/index.ts" }, { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "ToolProviderResult", "source": "packages/core/system-prompt/src/index.ts" }, diff --git a/tsconfig.build.json b/tsconfig.build.json index 30e266da46..42140ab323 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -25,6 +25,7 @@ { "path": "./packages/session-query/session-query" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, + { "path": "./packages/ui/commands" }, { "path": "./packages/goal/goal" }, { "path": "./packages/goal/tool-goal" }, { "path": "./packages/goal/goal-session" }, diff --git a/tsconfig.json b/tsconfig.json index d64438c4ab..fd05bdd929 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -36,6 +36,7 @@ { "path": "./packages/session-query/session-query" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, + { "path": "./packages/ui/commands" }, { "path": "./packages/goal/goal" }, { "path": "./packages/goal/tool-goal" }, { "path": "./packages/goal/goal-session" }, diff --git a/website/.vitepress/config/api-sidebar.json b/website/.vitepress/config/api-sidebar.json index 5fb3258805..db7dde160a 100644 --- a/website/.vitepress/config/api-sidebar.json +++ b/website/.vitepress/config/api-sidebar.json @@ -46,6 +46,10 @@ "text": "ctx.codeRuntime", "link": "/zh-CN/api/harness/code-runtime" }, + { + "text": "ctx.commands", + "link": "/zh-CN/api/harness/commands" + }, { "text": "ctx.compact", "link": "/zh-CN/api/harness/compact" diff --git a/website/zh-CN/api/harness/commands.md b/website/zh-CN/api/harness/commands.md new file mode 100644 index 0000000000..6f8ed33dfa --- /dev/null +++ b/website/zh-CN/api/harness/commands.md @@ -0,0 +1,97 @@ + + +# ctx.commands + +`CommandService` — provided by `@deepseek-ai/dsh-commands`. + +Human-command registry. Plain-context definitions are global; definitions registered through a command-injected child of an agent context shadow globals for that agent. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L216) + +### ctx.commands.register(definition) + +```ts website-api +/** + * Register a global or calling-agent-scoped command. + * @param definition - discovery metadata, surface mask, and direct UI handler. + * @returns the exact effect disposer that unregisters this definition. + */ +register(definition: CommandDefinition): () => void +``` + +Register a global or calling-agent-scoped command. + +- `definition` — discovery metadata, surface mask, and direct UI handler. + +**Returns** the exact effect disposer that unregisters this definition. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L229) + +### ctx.commands.list(agent, surface) + +```ts website-api +/** + * List the effective immutable command descriptors for one agent and surface. + * @param agent - exact receiving agent and scoped-layer key. + * @param surface - UI adapter requesting discovery metadata. + * @returns name-sorted descriptors after scoped shadowing and surface filtering. + */ +list(agent: Agent, surface: CommandSurface): readonly CommandDescriptor[] +``` + +List the effective immutable command descriptors for one agent and surface. + +- `agent` — exact receiving agent and scoped-layer key. +- `surface` — UI adapter requesting discovery metadata. + +**Returns** name-sorted descriptors after scoped shadowing and surface filtering. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L257) + +### ctx.commands.find(agent, surface, name) + +```ts website-api +/** + * Resolve one effective command definition. + * @param agent - exact receiving agent and scoped-layer key. + * @param surface - UI adapter performing the lookup. + * @param name - command name without a slash. + * @returns the scoped shadow or global definition when visible on the surface. + */ +find(agent: Agent, surface: CommandSurface, name: string): CommandDefinition | undefined +``` + +Resolve one effective command definition. + +- `agent` — exact receiving agent and scoped-layer key. +- `surface` — UI adapter performing the lookup. +- `name` — command name without a slash. + +**Returns** the scoped shadow or global definition when visible on the surface. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L272) + +### ctx.commands.execute(agent, surface, line, signal) + +```ts website-api +/** + * Parse and execute a known command without sending it to the model. + * @param agent - exact receiving agent. + * @param surface - dispatching UI adapter. + * @param line - complete slash-command line. + * @param signal - cancellation signal owned by the UI request. + * @returns a detached result, or `undefined` when syntax/name/surface does not resolve. + */ +async execute( agent: Agent, surface: CommandSurface, line: string, signal: AbortSignal, ): Promise +``` + +Parse and execute a known command without sending it to the model. + +- `agent` — exact receiving agent. +- `surface` — dispatching UI adapter. +- `line` — complete slash-command line. +- `signal` — cancellation signal owned by the UI request. + +**Returns** a detached result, or `undefined` when syntax/name/surface does not resolve. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L285) diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index e959832c12..4c6286f097 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -2,7 +2,7 @@ # Harness events -Every event the harness packages declare on the cordis event bus (44 total), grouped by scope. The **mode** is the dispatch semantics (`emit` fire-and-forget, `parallel` awaited, `serial` first-bail, `waterfall` veto-chain — a waterfall listener MUST call `next()` to delegate). +Every event the harness packages declare on the cordis event bus (45 total), grouped by scope. The **mode** is the dispatch semantics (`emit` fire-and-forget, `parallel` awaited, `serial` first-bail, `waterfall` veto-chain — a waterfall listener MUST call `next()` to delegate). ## agent/* @@ -472,6 +472,25 @@ Ask composed answerers for one decision. Return an outcome to claim the request [Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/user-approval/src/index.ts#L31) +## commands/* + +### commands/change + +**Mode:** `emit` + +```ts website-api +/** + * A command was registered or unregistered. This is an unfiltered registry + * notification because a global or scoped change may affect any UI view. + * @mode emit + */ +'commands/change'(): void +``` + +A command was registered or unregistered. This is an unfiltered registry notification because a global or scoped change may affect any UI view. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L93) + ## fs/* ### fs/edit-intent From 689e926c0ad55bcf94704fdc9749114d3401a6f6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:30:15 +0800 Subject: [PATCH 05/25] docs(goal): structure tool model experience --- packages/goal/tool-goal/README.md | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index a3987e32e1..b2332307f8 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -33,21 +33,37 @@ The value must be a positive safe integer. It supplies both the hard lower bound ### System prompt -**What the model sees**: A fixed goal policy says when semantic human intent warrants creation, requires exact read-before-update refs, explains rearming after resume/fork, and limits completion/blocking claims. The configured threshold is interpolated into that guidance. +#### What the model sees -**Token effect**: Small fixed input cost on every request where this plugin's prompt registration is in scope. +A fixed goal policy says when semantic human intent warrants creation, requires exact read-before-update refs, explains rearming after resume/fork, and limits completion/blocking claims. The configured threshold is interpolated into that guidance. -#### Goal policy +##### Goal policy ```markdown Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked. ``` +#### Token effect + +Small fixed input cost on every request where this plugin's prompt registration is in scope. + +#### KV Cache effect + +Prefix-stable while the plugin scope, configured threshold, and guidance text are unchanged. Activation, disposal, or configuration changes may invalidate reuse from this prompt section. + ### Tool schemas and results -**What the model sees**: The generated [`get_goal`, `create_goal`, and `update_goal` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal). Successful results are compact JSON. Mutation results are followed by the goal domain's raw `` snapshot after the tool batch. `activation` in a result is a live observation and never becomes replay authority. +#### What the model sees -**Token effect**: Fixed schema cost plus one compact result per call. Mutations also retain the domain snapshot until compaction. +The generated [`get_goal`, `create_goal`, and `update_goal` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal). Successful results are compact JSON. Mutation results are followed by the goal domain's raw `` snapshot after the tool batch. `activation` in a result is a live observation and never becomes replay authority. + +#### Token effect + +Fixed schema cost plus one compact result per call. Mutations also retain the domain snapshot until compaction. + +#### KV Cache effect + +Schemas are prefix-stable while their definitions and visibility are unchanged. Calls, results, and resulting goal snapshots append after the reusable request prefix without invalidating earlier entries. ## Known Limitations and Deferred Work From 34fe1677757649be9fcbb93495c98944acab6c23 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:34:03 +0800 Subject: [PATCH 06/25] docs(goal): describe round cache behavior --- packages/goal/goal-session/README.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/goal/goal-session/README.md b/packages/goal/goal-session/README.md index 0a73cfb75d..e0e74f749f 100644 --- a/packages/goal/goal-session/README.md +++ b/packages/goal/goal-session/README.md @@ -49,9 +49,17 @@ Cancellation is observe-before-act: the concrete loop emits `agent/cancel-reques ### Goal-round prompt -**What the model sees**: Each admitted round is one retained user-role `` block naming the full objective and positive round number. Earlier human messages, goal-state snapshots, assistant output, and tool records remain in the same session history. +#### What the model sees -**Token effect**: One fixed instruction block plus the objective is added per admitted round. Later requests resend retained rounds until compaction shadows them; no fresh agent or copied conversation prefix is created. +Each admitted round is one retained user-role `` block naming the full objective and positive round number. Earlier human messages, goal-state snapshots, assistant output, and tool records remain in the same session history. + +#### Token effect + +One fixed instruction block plus the objective is added per admitted round. Later requests resend retained rounds until compaction shadows them; no fresh agent or copied conversation prefix is created. + +#### KV Cache effect + +Append-only within an epoch: each admitted round extends the existing conversation after its reusable prefix. Compaction may replace the derived-history suffix and move the reusable boundary. ## Known Limitations and Deferred Work From 1a14b605695a35472b12afe2b1ed8724f9e9e00a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 02:38:14 +0800 Subject: [PATCH 07/25] test(goal): close terminal pause e2e --- .../tests/fixtures/goal/tool-goal/scripted-llm.ts | 2 +- packages/goal/tool-goal/tests/tool-goal.e2e.ts | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts b/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts index 39498f8e39..7b635ba0bb 100644 --- a/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts +++ b/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts @@ -75,7 +75,7 @@ class GoalScriptAdapter extends LlmAdapter { if (goal === undefined) throw new Error('scripted goal state missing') return toolCall('update_goal', { goal_id: goal.id, revision: goal.revision, action: 'pause' }) } - if (prompt.text === 'pause') return textReply('GOAL PAUSED') + if (prompt.text === 'pause') return textReply('UNEXPECTED CONTINUATION AFTER PAUSE') return textReply('UNEXPECTED PROMPT') } } diff --git a/packages/goal/tool-goal/tests/tool-goal.e2e.ts b/packages/goal/tool-goal/tests/tool-goal.e2e.ts index 037b91d18d..34f7bf243c 100644 --- a/packages/goal/tool-goal/tests/tool-goal.e2e.ts +++ b/packages/goal/tool-goal/tests/tool-goal.e2e.ts @@ -16,6 +16,7 @@ const configPath = fileURLToPath(new URL( const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) const PROCESS_TIMEOUT_MS = 30_000 const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000 +const PAUSED_RESULT = '"phase":"paused"' let child: ChildProcessWithoutNullStreams | undefined let workdir: string | undefined @@ -68,7 +69,8 @@ async function runComposition(): Promise<{ stdout: string; stderr: string }> { pauseSent = true proc.stdin.write('pause\n') } - if (!inputClosed && stdout.includes('GOAL PAUSED')) { + const pausedAt = stdout.indexOf(PAUSED_RESULT) + if (!inputClosed && pausedAt >= 0 && stdout.indexOf('\n> ', pausedAt) >= 0) { inputClosed = true proc.stdin.end() } @@ -98,7 +100,8 @@ describe('goal tools through a real Loader, app, and stdio process', () => { expect(stderr).not.toContain('UNHANDLED') expect(stdout).toContain('goal-tools e2e ready.') expect(stdout).toContain('GOAL CREATED') - expect(stdout).toContain('GOAL PAUSED') + expect(stdout).toContain(PAUSED_RESULT) + expect(stdout).not.toContain('UNEXPECTED CONTINUATION AFTER PAUSE') const logs = await jsonlFiles(join(workdir as string, '.sessions')) expect(logs).toHaveLength(1) From 89f9900be1ad5d5b02c376c9dd61eaf9bc143e01 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 08:47:05 +0800 Subject: [PATCH 08/25] fix(goal): preserve interactive human turns --- ...26-07-19-model-facing-goal-tools.i18n.yaml | 4 +- .../2026-07-19-model-facing-goal-tools.md | 7 +- .../2026-07-19-model-facing-goal-tools.zh.md | 7 +- docs/cordis-catalog/events.md | 30 ++++----- docs/event-producer-consumer.md | 30 ++++----- docs/tool-catalog.md | 6 +- .../fixtures/goal/tool-goal/scripted-llm.ts | 4 +- packages/core/agent/README.md | 2 +- packages/core/agent/src/types.ts | 5 +- packages/goal/tool-goal/README.md | 5 +- packages/goal/tool-goal/src/authority.ts | 6 +- packages/goal/tool-goal/src/index.ts | 47 +++++++------ .../goal/tool-goal/tests/tool-goal.e2e.ts | 2 +- .../goal/tool-goal/tests/tool-goal.spec.ts | 67 +++++++++++++++++-- website/zh-CN/api/harness/events.md | 30 ++++----- 15 files changed, 163 insertions(+), 89 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml index af951c46ff..f857c5360e 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml @@ -2,5 +2,5 @@ # 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: # pnpm run verify-translation-pairing --write -2026-07-19-model-facing-goal-tools.md: 3d9a33566d3ae27f803cbe56b7464bd1da17c1eb -2026-07-19-model-facing-goal-tools.zh.md: 45ca2dfdc3523a3598add845224cda6f58d2c738 +2026-07-19-model-facing-goal-tools.md: e37eaabecfd1984e1198c26a460e78a92375dac1 +2026-07-19-model-facing-goal-tools.zh.md: c0d289271d2a8053193299c16a2bc9477f2f1038 diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md index 3d9a33566d..e37eaabecf 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md @@ -22,13 +22,13 @@ The prompt tells the model that it may infer goal intent from a direct human req All three tools use exclusive execution so a model-ordered batch observes prior mutations and their new revisions. Results are compact JSON. ACP presentation is a pure function of arguments and uses generic read or mutation cards; activation is reported only as live observation and is never written into replay state. -A successful update that leaves the goal stopped contributes the existing terminal `agent/turn-stop` decision for that physical turn, preventing an unnecessary follow-up request after pause, block, or completion. A later successful resume in the same turn removes that contribution. +An autonomous goal round that successfully reports completion or blocking contributes the existing terminal `agent/turn-stop` decision for that physical turn, preventing an unnecessary follow-up request. Direct-human mutations do not contribute a terminal stop: the assistant can acknowledge the change, and concurrent human steering remains available to ordinary continuation folding. ### Execution authority Every call requires an `exec.agent` that is the exact running object in `AgentRegistry`, is the current inherited driver initiator, and has an open turn. These are execution-time checks and cannot be bypassed by prompt injection or hand-authored tool arguments. -Create, edit, pause, and resume additionally require an accepted user message or user steering event in the current turn of a runtime-root agent. Root ownership is derived from the live agent graph rather than durable fork ancestry: a resumed fork can receive direct human authority, while a live child remains a subagent and cannot mutate these states. The runtime proves provenance, not whether the human's wording semantically warrants creation or resumption; that interpretation remains with the model. +Create, edit, pause, and resume additionally require an accepted user message or user steering event in the current turn of a runtime-root agent. Root ownership is derived from the live agent graph rather than durable fork ancestry: a resumed fork can receive direct human authority, while a live child remains a subagent and cannot mutate these states. User source is a host attestation: `Agent.send()` and `steer()` default an omitted source to `{ kind: 'user' }`, so non-human producers must label their own content. The runtime proves provenance, not whether the human's wording semantically warrants creation or resumption; that interpretation remains with the model. Complete and blocked accept either direct-human authority or the exact current goal round. Goal-round authority requires a goal-sourced `user/message` whose goal id, revision, and round all equal the folded current goal. It grants only the two terminal reports. Direct human authority may stop a goal immediately. @@ -38,7 +38,7 @@ Complete and blocked accept either direct-human authority or the exact current g ## Testing -Unit coverage pins registration and disposal, exclusive scheduling, generated prompt policy, generic presentation, direct-human creation in a non-English turn, exact live-agent and driver checks, root-versus-child authority, steering, mismatched initiators, read/create/edit/pause/resume behavior, rearming after a session-start edge, compare-and-set and argument failures, exact goal-round completion, the configured blocking threshold, and immediate human blocking. A keyless Loader/stdio process test mounts the real goal, tool, loop, and persistence plugins through `cordis.yml`, drives scripted model tool calls, and reads the JSONL externally to verify the model-visible create/pause snapshots, structured tool results, and configured prompt text. +Unit coverage pins registration and disposal, exclusive scheduling, generated prompt policy, generic presentation, direct-human creation in a non-English turn, exact/stale/non-running agent and driver checks, live-child rejection, resumed-fork root authority, steering, mismatched initiators, read/create/edit/pause/resume behavior, rearming after a session-start edge, authority-before-conditional-argument failures, exact goal-round completion, autonomous-only terminal stopping, the configured blocking threshold, and immediate human blocking. A keyless Loader/stdio process test mounts the real goal, tool, loop, and persistence plugins through `cordis.yml`, drives scripted model tool calls through a human pause and assistant acknowledgment, and reads the JSONL externally to verify the model-visible create/pause snapshots, structured tool results, and configured prompt text. ## Alternatives considered @@ -61,5 +61,6 @@ Unit coverage pins registration and disposal, exclusive scheduling, generated pr - Semantic classification of a substantial goal, a request to continue, objective completion, and the same blocking condition remains model judgment. An independent evaluator or completion certificate is deferred. - These tools mutate goal state but do not schedule goal rounds, classify abnormal driver stops, or cancel an active turn; the same-session driver owns those behaviors. +- Goal-round authority is dormant unless a separately mounted continuation driver admits goal-sourced user turns; this tool package never manufactures that authority itself. - Human slash-command discovery and rendering are deferred to the command-surface layer. - A scope can hide tool registrations while leaving the independently registered prompt section visible unless the deployment scopes both together. diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md index 45ca2dfdc3..c0d289271d 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md @@ -22,13 +22,13 @@ Status: implemented 三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。ACP 展示是参数的纯函数,使用通用读取或变更卡片;激活态仅作为实时观察返回,绝不会写入回放状态。 -成功更新后若目标处于停止状态,插件会为该物理轮次贡献现有的终止型 `agent/turn-stop` 决策,避免在暂停、阻塞或完成后再发起一次不必要的模型请求。同一轮次中后续成功的恢复会移除该贡献。 +自主目标回合成功报告完成或阻塞后,插件会为该物理轮次贡献现有的终止型 `agent/turn-stop` 决策,避免再发起一次不必要的模型请求。直接人类发起的变更不会贡献终止决策:智能体可以确认该变更,并且并发的人类 steering(转向)仍可参与普通的继续执行折叠。 ### 执行权限 每次调用都要求存在 `exec.agent`,且它必须是 `AgentRegistry` 中完全相同的运行中对象、当前继承的驱动发起者,并处于开放轮次内。这些检查在执行时进行,不能通过提示词注入或手写工具参数绕过。 -创建、编辑、暂停与恢复还要求运行时根智能体的当前轮次已经接纳一条用户消息或用户 steering(转向)事件。根所有权来自实时智能体图,而非持久的 fork 祖先关系:恢复后的派生会话可以接收新的直接人类权限,实时子智能体则仍然是子智能体,不能改变这些状态。运行时证明来源,而不判断人类措辞在语义上是否足以创建或恢复目标;该解释仍由模型完成。 +创建、编辑、暂停与恢复还要求运行时根智能体的当前轮次已经接纳一条用户消息或用户 steering(转向)事件。根所有权来自实时智能体图,而非持久的 fork 祖先关系:恢复后的派生会话可以接收新的直接人类权限,实时子智能体则仍然是子智能体,不能改变这些状态。用户来源是宿主的证明:`Agent.send()` 和 `steer()` 会把省略的来源默认为 `{ kind: 'user' }`,因此非人类生产者必须标注自己的内容。运行时证明来源,而不判断人类措辞在语义上是否足以创建或恢复目标;该解释仍由模型完成。 完成与阻塞既接受直接人类权限,也接受准确的当前目标回合。目标回合权限要求存在一条来源为目标的 `user/message`,其中目标 id、修订号和回合都与折叠后的当前目标相等。它只授予这两种终止报告权限。直接人类权限可以立即停止目标。 @@ -38,7 +38,7 @@ Status: implemented ## 测试 -单元测试固定注册与释放、独占调度、生成的提示词策略、通用展示、非英语轮次中的直接人类创建、精确实时智能体与驱动检查、根与子智能体权限、steering、发起者不匹配、读取/创建/编辑/暂停/恢复行为、会话启动边沿后的重新激活、比较并交换与参数失败、准确目标回合的完成、可配置阻塞阈值,以及人类立即阻塞。无密钥 Loader/stdio 进程测试通过 `cordis.yml` 挂载真实的目标、工具、循环和持久化插件,驱动脚本化模型工具调用,并从外部读取 JSONL,以验证模型可见的创建/暂停快照、结构化工具结果和配置后的提示词文本。 +单元测试固定注册与释放、独占调度、生成的提示词策略、通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后派生根的权限、steering、发起者不匹配、读取/创建/编辑/暂停/恢复行为、会话启动边沿后的重新激活、权限先于条件参数失败、准确目标回合的完成、仅自主回合触发终止、可配置阻塞阈值,以及人类立即阻塞。无密钥 Loader/stdio 进程测试通过 `cordis.yml` 挂载真实的目标、工具、循环和持久化插件,驱动脚本化模型工具调用经过人类暂停与智能体确认,并从外部读取 JSONL,以验证模型可见的创建/暂停快照、结构化工具结果和配置后的提示词文本。 ## 考虑过的替代方案 @@ -61,5 +61,6 @@ Status: implemented - 是否属于重大目标、是否要求继续、目标是否完成以及阻塞条件是否相同,仍由模型进行语义分类。独立评估器或完成证书予以延期。 - 这些工具会改变目标状态,但不调度目标回合、不分类异常驱动停止,也不取消活跃轮次;这些行为由同会话驱动器负责。 +- 除非另行挂载的继续执行驱动器接纳了目标来源的用户轮次,否则目标回合权限路径处于休眠状态;本工具包本身不会制造这种权限。 - 面向人类的斜杠命令发现与渲染延期到命令表面层。 - 若部署没有同时设定两个注册项的作用域,某个作用域可能隐藏工具注册,却保留独立注册的提示词段。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index c90eacde5e..cc27559e84 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -33,7 +33,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:147`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:150`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -53,7 +53,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:156`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:159`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -75,7 +75,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:311`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:314`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial @@ -98,7 +98,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:264`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:267`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -121,7 +121,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:204`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:207`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -142,7 +142,7 @@ Allow, rewrite, or block one drained prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:217`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -163,7 +163,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:175`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:178`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -186,7 +186,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:226`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:229`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -211,7 +211,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens Types: [Agent](../core-data-structures/core.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:278`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:281`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -237,7 +237,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:241`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:244`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -259,7 +259,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:188`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:191`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -279,7 +279,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:165`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:168`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -301,7 +301,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:252`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:255`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -322,7 +322,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:288`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:291`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -343,7 +343,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:298`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:301`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d48349cdf0..b7a7f6bb12 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,21 +8,21 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:362`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:147`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:156`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:264`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:204`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:214`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:175`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:226`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:278`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:241`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:188`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:165`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:252`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:288`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:150`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:159`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:314`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | +| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:267`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:207`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:217`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:178`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:229`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:281`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:244`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:191`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:168`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:255`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:291`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:301`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 2bd4f5d3de..3b63dceb1c 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -410,7 +410,7 @@ Create one persisted same-session completion goal when the current direct human }, "max_goal_rounds": { "type": "number", - "description": "Optional positive safe-integer cap; omission uses the goal-domain deployment default." + "description": "Optional positive safe-integer limit on automatic continuation rounds." } }, "required": [ @@ -423,7 +423,7 @@ Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/ ### `get_goal` -Read the current same-session goal, including its exact id/revision, durable phase, admitted round count, cap, and live process-local activation. Call this before updating a goal. +Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal. ```json { @@ -436,7 +436,7 @@ Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/ ### `update_goal` -Update the exact current goal revision. edit, pause, and resume require a direct top-level human turn. complete and blocked additionally accept the exact admitted goal round. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds. +Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds. ```json { diff --git a/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts b/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts index 7b635ba0bb..310737050d 100644 --- a/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts +++ b/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts @@ -1,4 +1,4 @@ -/** Deterministic adapter that creates, reads, then pauses one goal. */ +/** Deterministic adapter that creates, reads, pauses, then acknowledges one goal. */ import type { Context } from 'cordis' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' @@ -75,7 +75,7 @@ class GoalScriptAdapter extends LlmAdapter { if (goal === undefined) throw new Error('scripted goal state missing') return toolCall('update_goal', { goal_id: goal.id, revision: goal.revision, action: 'pause' }) } - if (prompt.text === 'pause') return textReply('UNEXPECTED CONTINUATION AFTER PAUSE') + if (prompt.text === 'pause') return textReply('GOAL PAUSED') return textReply('UNEXPECTED PROMPT') } } diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index c2b46f210d..6d2df3d957 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -54,7 +54,7 @@ Turn and step boundaries and the model token stream are durable `session/event` The handle every plugin programs against: -- `agent.send(content, options?)` — queue a message; starts a turn when idle. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). +- `agent.send(content, options?)` — queue a message; starts a turn when idle. Omitting `options.source` attests direct human input as `{ kind: 'user' }` and may authorize policy consumers, so plugins, schedulers, and other non-human producers provide their own source. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). - `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle - `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message`. `options.envelope` defaults to the canonical `` framing and may be `'raw'` when the caller owns a complete familiar frame; `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). - `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op. diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 702861f407..e9ee359da8 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -25,7 +25,10 @@ export interface AgentOptions { model?: string } -/** Message options; an omitted source resolves to `{ kind: 'user' }`, so plugins must label their own content. */ +/** + * Message options. An omitted source attests direct human input as `{ kind: 'user' }` + * and may authorize policy consumers, so non-human producers must label their content. + */ export interface SendOptions { source?: MessageSource } diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index 42f62b7ce9..1ac03ac04a 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -10,12 +10,14 @@ The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations. -A successful mutation that leaves the goal stopped contributes the existing terminal `agent/turn-stop` decision for that physical turn. A later same-turn resume clears the contribution. This avoids an extra model request after pause, block, or completion without changing ordinary loop continuation. +An autonomous goal round that successfully reports `complete` or `blocked` contributes the existing terminal `agent/turn-stop` decision for that physical turn. Direct-human mutations never contribute this stop: the assistant may acknowledge the change and concurrent human steering remains available to the loop. ## Authority 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. + 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. Direct human authority may stop a goal immediately. ## Config @@ -70,4 +72,5 @@ Schemas are prefix-stable while their definitions and visibility are unchanged. - **Semantic intent remains model judgment** — execution can prove direct human provenance, not whether a request is substantial enough to merit a goal. - **Same-condition blocking remains model judgment** — the runtime enforces distinct admitted-round count, not semantic equivalence of obstacles; an independent evaluator is deferred. - **No scheduling or UI commands** — these tools mutate state only; the same-session driver and human command surfaces are separate stack layers. +- **Goal-round authority requires a driver** — the autonomous `complete`/`blocked` path is dormant unless a continuation driver admits goal-sourced user turns; mounting this tool package alone does not create them. - **Prompt registration is independent of filtering** — a scope may hide the tools while retaining their guidance unless the deployment scopes both registrations together. diff --git a/packages/goal/tool-goal/src/authority.ts b/packages/goal/tool-goal/src/authority.ts index a2238aab9c..41fe713dc6 100644 --- a/packages/goal/tool-goal/src/authority.ts +++ b/packages/goal/tool-goal/src/authority.ts @@ -62,7 +62,11 @@ export function goalToolExecution(ctx: Context, exec: ToolRunContext): GoalToolE return { agent, ...openTurn(agent) } } -/** Whether an accepted human message 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 + * producers must supply their own source rather than inheriting this authority. + */ function hasDirectHumanInput(ctx: Context, execution: GoalToolExecution): boolean { if (!ctx.agents.roots().includes(execution.agent)) return false return execution.events.some(event => diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 29068a4afa..5c395898b7 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -50,8 +50,8 @@ const CREATE_DESCRIPTION = + 'trivial single-turn work. Execution rejects non-human and subagent authority.' const GET_DESCRIPTION = - 'Read the current same-session goal, including its exact id/revision, durable phase, admitted ' - + 'round count, cap, and live process-local activation. Call this before updating a goal.' + 'Read the current same-session goal, including its exact id/revision, objective, phase, completed ' + + 'continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal.' /** Render policy guidance with its deployment-selected blocked threshold. */ function guidance(blockedAfter: number): string { @@ -107,13 +107,14 @@ function present(title: string, kind: 'read' | 'other', rawInput?: unknown): Gen return { card: 'generic', title, kind, ...rawInput === undefined ? {} : { rawInput } } } -/** Remember whether one successful mutation makes this turn terminal. */ +/** Remember whether one autonomous terminal report should stop this turn. */ function observeMutation( terminalTurns: WeakMap, execution: GoalToolExecution, goal: GoalView, + autonomousTerminal: boolean, ): void { - if (goal.phase === 'active' && goal.activation === 'armed') { + if (!autonomousTerminal || (goal.phase === 'active' && goal.activation === 'armed')) { terminalTurns.delete(execution.agent) return } @@ -123,6 +124,8 @@ function observeMutation( /** Register the three Codex-shaped goal tools and their shared policy section. */ export function apply(ctx: Context, config: Config): void { const resolved = resolveConfig(config) + // A stale entry cannot match a later loop turn because turn numbers increase + // monotonically within the agent's fixed session. const terminalTurns = new WeakMap() ctx.on('agent/turn-stop', (agent, turn) => { if (terminalTurns.get(agent) !== turn) return undefined @@ -160,7 +163,7 @@ export function apply(ctx: Context, config: Config): void { }, max_goal_rounds: { type: 'number', - description: 'Optional positive safe-integer cap; omission uses the goal-domain deployment default.', + description: 'Optional positive safe-integer limit on automatic continuation rounds.', }, }, execute(args, exec) { @@ -170,7 +173,7 @@ export function apply(ctx: Context, config: Config): void { objective: args.objective, ...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds }, }) - observeMutation(terminalTurns, execution, goal) + observeMutation(terminalTurns, execution, goal, false) return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) }, presentCall: args => present('Create goal', 'other', args.objective), @@ -179,8 +182,8 @@ export function apply(ctx: Context, config: Config): void { ctx.tools.register(defineTool({ name: 'update_goal', description: 'Update the exact current goal revision. edit, pause, and resume require a direct ' - + 'top-level human turn. complete and blocked additionally accept the exact admitted goal ' - + 'round. blocked is rejected before the configured minimum round count; the model remains ' + + 'top-level human request. During an automatic continuation of the current goal, complete ' + + 'and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains ' + 'responsible for judging that the same condition persisted across those rounds.', parameters: { goal_id: { type: 'string', required: true, description: 'Exact id returned by get_goal.' }, @@ -204,27 +207,33 @@ export function apply(ctx: Context, config: Config): void { if (args.action === 'edit') { requireDirectHuman(ctx, execution) const goal = ctx.goals.edit(execution.agent, ref, replacements) - observeMutation(terminalTurns, execution, goal) + observeMutation(terminalTurns, execution, goal, false) return Promise.resolve([{ type: 'text', text: renderGoal(goal), }]) } + if (args.action === 'pause' || args.action === 'resume') { + requireDirectHuman(ctx, execution) + if (args.objective !== undefined || args.max_goal_rounds !== undefined) { + throw new HarnessError( + 'objective and max_goal_rounds are valid only with action edit', + 'GOAL_TOOL_INVALID_UPDATE', + ) + } + const goal = args.action === 'pause' + ? ctx.goals.pause(execution.agent, ref) + : ctx.goals.resume(execution.agent, ref) + observeMutation(terminalTurns, execution, goal, false) + return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) + } + const authority = completionAuthority(ctx, execution) if (args.objective !== undefined || args.max_goal_rounds !== undefined) { throw new HarnessError( 'objective and max_goal_rounds are valid only with action edit', 'GOAL_TOOL_INVALID_UPDATE', ) } - if (args.action === 'pause' || args.action === 'resume') { - requireDirectHuman(ctx, execution) - const goal = args.action === 'pause' - ? ctx.goals.pause(execution.agent, ref) - : ctx.goals.resume(execution.agent, ref) - observeMutation(terminalTurns, execution, goal) - return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) - } - const authority = completionAuthority(ctx, execution) if (args.action === 'blocked' && authority.kind === 'goal-round' && authority.goal.roundsStarted < resolved.blockedAfterConsecutiveRounds) { throw new HarnessError( @@ -236,7 +245,7 @@ export function apply(ctx: Context, config: Config): void { const goal = args.action === 'complete' ? ctx.goals.complete(execution.agent, ref) : ctx.goals.block(execution.agent, ref) - observeMutation(terminalTurns, execution, goal) + observeMutation(terminalTurns, execution, goal, authority.kind === 'goal-round') return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) }, presentCall: args => present( diff --git a/packages/goal/tool-goal/tests/tool-goal.e2e.ts b/packages/goal/tool-goal/tests/tool-goal.e2e.ts index 34f7bf243c..b19a4ee851 100644 --- a/packages/goal/tool-goal/tests/tool-goal.e2e.ts +++ b/packages/goal/tool-goal/tests/tool-goal.e2e.ts @@ -101,7 +101,7 @@ describe('goal tools through a real Loader, app, and stdio process', () => { expect(stdout).toContain('goal-tools e2e ready.') expect(stdout).toContain('GOAL CREATED') expect(stdout).toContain(PAUSED_RESULT) - expect(stdout).not.toContain('UNEXPECTED CONTINUATION AFTER PAUSE') + expect(stdout).toContain('GOAL PAUSED') const logs = await jsonlFiles(join(workdir as string, '.sessions')) expect(logs).toHaveLength(1) diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 2532b16a15..3950bf1e63 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -7,7 +7,7 @@ import GoalService, { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef } from '@deepseek-ai/dsh-goal' import { CallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' @@ -20,8 +20,8 @@ interface StubAgent { } /** Build one registry-compatible live agent whose injections append in place. */ -function stubAgent(rawId: string): StubAgent { - const session = new Session(SessionId(rawId)) +function stubAgent(rawId: string, supplied?: Session): StubAgent { + const session = supplied ?? new Session(SessionId(rawId)) let status: AgentStatus = 'running' const agent: Agent = { id: session.id, @@ -219,6 +219,42 @@ describe('goal tool execution authority', () => { expect(childResult.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') }) + it('rejects stale agent objects and agents outside running status through the executor', async () => { + const { ctx, root } = await harness() + openTurn(root, { kind: 'user' }) + const stale = { ...root.agent } + const staleResult = await execute(ctx, 'get_goal', {}, stale, stale) + expect(staleResult.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + + root.setStatus('idle') + const idleResult = await execute(ctx, 'get_goal', {}, root.agent) + expect(idleResult.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + }) + + it('treats a fork resumed as a runtime root as direct-human authority', async () => { + const { ctx, root } = await harness() + const originalTurn = openTurn(root, { kind: 'user' }) + const created = ctx.goals.create(root.agent, { objective: 'resume the fork' }) + closeTurn(root, originalTurn) + const forkId = SessionId('goal-tool-resumed-fork') + const forkSession = new Session(forkId, root.session.events, { + version: SESSION_FORMAT_VERSION, + id: forkId, + createdAt: Date.now(), + parentSession: root.session.id, + seedLength: root.session.seq, + }) + const fork = stubAgent(forkId, forkSession) + ctx.agents.register(fork.agent) + expect(ctx.goals.get(fork.agent)).toMatchObject({ id: created.id, activation: 'disarmed' }) + + openTurn(fork, { kind: 'user' }, '继续这个目标') + const resumed = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'resume', + }, fork.agent) + expect(resultGoal(resumed)).toMatchObject({ id: created.id, revision: 2, phase: 'active' }) + }) + it('rejects calls before a turn and after its end boundary', async () => { const { ctx, root } = await harness() const before = await execute(ctx, 'get_goal', {}, root.agent) @@ -237,6 +273,10 @@ describe('goal tool execution authority', () => { goal_id: 'goal-missing', revision: 1, action: 'complete', }, root.agent) expect(result.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') + const malformed = await execute(ctx, 'update_goal', { + goal_id: 'goal-missing', revision: 1, action: 'pause', objective: 'probe', + }, root.agent) + expect(malformed.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') }) it('accepts direct human steering in a goal-sourced root turn', async () => { @@ -290,16 +330,29 @@ describe('goal tool state transitions', () => { expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', 1)).toBeUndefined() }) - it('stops the current turn after a successful stopped-state mutation', async () => { + it('terminal-stops an autonomous completion but leaves a human pause interactive', async () => { const { ctx, root } = await harness() - openTurn(root, { kind: 'user' }) + const humanTurn = openTurn(root, { kind: 'user' }) const created = ctx.goals.create(root.agent, { objective: 'pause cleanly' }) const paused = await execute(ctx, 'update_goal', { goal_id: created.id, revision: created.revision, action: 'pause', }, root.agent) expect(resultGoal(paused)).toMatchObject({ phase: 'paused' }) - expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', 1)).toEqual({ action: 'stop' }) - expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', 1)).toBeUndefined() + expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', humanTurn)).toBeUndefined() + const resumed = resultGoal(await execute(ctx, 'update_goal', { + goal_id: created.id, revision: 2, action: 'resume', + }, root.agent)) + closeTurn(root, humanTurn) + + const roundTurn = openTurn(root, { + kind: 'goal', goalId: created.id, revision: resumed['revision'] as number, round: 1, + }) + const complete = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: resumed['revision'], action: 'complete', + }, root.agent) + expect(resultGoal(complete)).toMatchObject({ phase: 'complete' }) + expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', roundTurn)).toEqual({ action: 'stop' }) + expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', roundTurn)).toBeUndefined() }) it('rearms a restored active goal only after a new direct human prompt', async () => { diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index ebe71dc559..a880973ba5 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -28,7 +28,7 @@ A fully configured agent and live session were published. Setup is composition-o - `agent` — the newly registered agent with its live session and completed setup. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L147) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L150) ### agent/disposed @@ -50,7 +50,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef - `agent` — the exact agent removed from the registry. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L156) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L159) ### agent/error @@ -77,7 +77,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w - `step` — the step at which the failure surfaced. - `error` — the failure, verbatim. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L311) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L314) ### agent/post-step @@ -105,7 +105,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in - `step` — the open step number. - `signal` — the turn abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L264) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L267) ### agent/pre-step @@ -133,7 +133,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending - `step` — the pending step number. - `signal` — the turn abort signal. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L204) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L207) ### agent/prompt-submit @@ -158,7 +158,7 @@ Allow, rewrite, or block one drained prompt before it becomes a user message. Ca - `content` — the drained message's blocks, as queued. - `source` — the message's resolved source. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L214) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L217) ### agent/queued @@ -183,7 +183,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already - `content` — the accepted content blocks retained by the inbox. - `info` — the accepted source plus whether it entered as steering. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L175) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L178) ### agent/request @@ -211,7 +211,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha - `step` — the step whose request this is. - `config` — the config the loop would use (frozen); return a replacement to switch. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L226) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L229) ### agent/request-error @@ -243,7 +243,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens - `retryAttempt` — zero-based number of prior recovery retries. - `signal` — the turn abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L278) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L281) ### agent/session-prefix @@ -273,7 +273,7 @@ Compose request-only messages placed before derived history. The frozen result i - `prefix` — the frozen seed; return an extended replacement. - `signal` — aborts composition when the step is torn down. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L241) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L244) ### agent/session-start @@ -298,7 +298,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to - `agent` — the agent whose session lifecycle began. - `source` — why the session started (fresh startup, resume, …). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L188) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L191) ### agent/status @@ -321,7 +321,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no - `agent` — the agent whose status flipped. - `status` — the status just entered (the transition's destination). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L165) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L168) ### agent/step-result @@ -348,7 +348,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va - `step` — the step that produced the message. - `message` — the assistant message as assembled from the stream. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L252) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L255) ### agent/turn-continuation @@ -373,7 +373,7 @@ Override whether the turn continues. The default continues after tool calls or s - `turn` — the turn being continued or stopped. - `defaultDecision` — what the loop would do absent an override. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L288) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L291) ### agent/turn-stop @@ -397,7 +397,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a - `agent` — the agent whose composed continuation outcome may be stopped. - `turn` — the turn at its terminal-stop checkpoint. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L298) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L301) ## agent-loop/* From 979fbbd96108a8fa6979992a1ac86cd14c2f612c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:07:15 +0800 Subject: [PATCH 09/25] fix(goal): harden driver cancellation settlement --- ...9-same-session-goal-round-driver.i18n.yaml | 4 +- ...26-07-19-same-session-goal-round-driver.md | 8 +-- ...07-19-same-session-goal-round-driver.zh.md | 8 +-- packages/goal/goal-session/README.md | 7 +-- packages/goal/goal-session/src/index.ts | 16 ++++-- .../goal-session/tests/goal-session.spec.ts | 49 ++++++++++++++++++- 6 files changed, 74 insertions(+), 18 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml index 9a5cf40202..d18d50594b 100644 --- a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml @@ -2,5 +2,5 @@ # 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: # pnpm run verify-translation-pairing --write -2026-07-19-same-session-goal-round-driver.md: 2fc27ab28bff5e192e43d30afc6cd796fd3394e1 -2026-07-19-same-session-goal-round-driver.zh.md: e9610c94eca48fb4d06be505a8cdfffb6c272012 +2026-07-19-same-session-goal-round-driver.md: 94c0a870c46d6b7f618faf3f0a4b01c141bec358 +2026-07-19-same-session-goal-round-driver.zh.md: 9fdff05ffb722ef683bc06acee606f7897e5afc6 diff --git a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md index 2fc27ab28b..94c0a870c4 100644 --- a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md +++ b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md @@ -39,7 +39,7 @@ The driver classifies one closed goal-owned turn as follows: | Turn result | Action | |---|---| | durable `completed` | continue while active/armed and under cap | -| broad cancellation / `aborted` | pause and disarm | +| cancellation of a reserved/admitted goal round, or its `aborted` result | pause and disarm | | `error` with code `RATE_LIMIT` | mark `usage-limited` | | other `error` | block | | `max-tokens` | block | @@ -52,9 +52,9 @@ No abnormal outcome requests an automatic retry. A later human prompt can ask to ### Durability and cancellation seam -Every `goal/changed` notification creates a checkpoint obligation. The driver awaits `ctx.sessions.flush(session)` before reserving work, then checks for a newer mutation, agent lifecycle change, or competing prompt. Turn-end flush failure is reported by the existing `agent/error` notification after `turn/end`; the driver associates it with the exact attempt and disarms before the next idle decision. +Every `goal/changed` notification creates a checkpoint obligation. The driver awaits `ctx.sessions.flush(session)` before reserving work, then checks for a newer mutation, agent lifecycle change, or competing prompt. Turn-end flush failure is reported by the existing `agent/error` notification after `turn/end`; the driver finds that exact closed turn even when a concurrent one-shot injection appended a later turn, associates the failure with the exact attempt, and disarms before the next idle decision. -Broad cancellation previously exposed only its effects after queues were cleared or the request aborted. The public agent vocabulary now includes observe-only `agent/cancel-requested(agent, reason)`. The concrete loop emits it for effective cancellation before either action; fused notification containment means a broken listener cannot veto cancellation. The goal driver uses this edge to clear its reservation and pause an active armed goal before the loop destroys the queued-work evidence. +Broad cancellation previously exposed only its effects after queues were cleared or the request aborted. The public agent vocabulary now includes observe-only `agent/cancel-requested(agent, reason)`. The concrete loop emits it for effective cancellation before either action; fused notification containment means a broken listener cannot veto cancellation. The goal driver uses this edge to clear its reservation before the loop destroys the queued-work evidence. When that reservation is a queued or admitted goal attempt, cancellation durably pauses the goal; when cancellation belongs to unrelated human work with no goal attempt, it only removes process-local activation. If the pause mutation throws, the driver falls back to disarming rather than allowing cancelled automatic work to restart. This is a coordination notification, not a second stop API. `Agent.cancel()` remains the only public broad cancellation verb, idle calls remain no-ops, and custom `Agent` implementations that claim the interface must honor the event ordering if consumers depend on it. @@ -68,7 +68,7 @@ An inbox acceptance can win the microtask race immediately before plugin unload ## Testing -The unit suite uses the real agent loop and session service with only the model scripted. It covers exact sequential admission and cap enforcement, load/resume inertness, every outcome classification, rate limiting, request errors, max tokens, downstream prompt veto, pre-admission and in-flight cancellation, human-input ordering, queued and downstream revision races, forged goal attribution, failed mutation and turn checkpoints, scheduler and custom-agent failures, session-start reset, exact lifecycle retirement, and queued/running plugin teardown. The new driver source has per-file 100% statement, branch, function, and line coverage. +The unit suite uses the real agent loop and session service with only the model scripted. It covers exact sequential admission and cap enforcement, load/resume inertness, every outcome classification, rate limiting, request errors, max tokens, downstream prompt veto, pre-admission and in-flight cancellation, unrelated-human cancellation, failed-pause fallback, human-input ordering, queued and downstream revision races, forged goal attribution, failed mutation and turn checkpoints including a later one-shot injection, scheduler and custom-agent failures, session-start reset, exact lifecycle retirement, and queued/running plugin teardown. The new driver source has per-file 100% statement, branch, function, and line coverage. A keyless Loader/stdio process test mounts the real goal domain, goal tools, goal driver, agent loop, persistence, and deterministic adapter through `cordis.yml`. One human turn creates a two-round goal; round one stops normally; round two reads the exact ref and completes it. The external JSONL assertion proves one session, round sources `1, 2`, unchanged round revision, final complete revision, five model steps, and no extra request after the terminal completion tool. diff --git a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md index e9610c94ec..9fdff05ffb 100644 --- a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md @@ -39,7 +39,7 @@ Status: implemented | 轮次结果 | 动作 | |---|---| | 持久的 `completed` | 目标仍 active/armed 且未到上限时继续 | -| 广义取消 / `aborted` | 暂停并解除激活 | +| 取消已预留/接纳的目标回合,或该回合产生 `aborted` 结果 | 暂停并解除激活 | | 代码为 `RATE_LIMIT` 的 `error` | 标记为 `usage-limited` | | 其他 `error` | 阻塞 | | `max-tokens` | 阻塞 | @@ -52,9 +52,9 @@ Status: implemented ### 持久性与取消接缝 -每次 `goal/changed` 通知都会产生一个检查点义务。驱动器在预留工作前等待 `ctx.sessions.flush(session)`,随后检查是否出现了更新的变更、agent 生命周期变化或竞争提示词。轮次结束时的 flush 失败会在 `turn/end` 之后通过现有 `agent/error` 通知报告;驱动器把它关联到精确尝试,并在下一次空闲决策前解除激活。 +每次 `goal/changed` 通知都会产生一个检查点义务。驱动器在预留工作前等待 `ctx.sessions.flush(session)`,随后检查是否出现了更新的变更、agent 生命周期变化或竞争提示词。轮次结束时的 flush 失败会在 `turn/end` 之后通过现有 `agent/error` 通知报告;即使并发的一次性注入已追加后续轮次,驱动器仍会找到该精确的已关闭轮次,把失败关联到精确尝试,并在下一次空闲决策前解除激活。 -广义取消此前只在队列已清除或请求已中止后暴露结果。公共 agent 词汇现在新增只观察的 `agent/cancel-requested(agent, reason)`。具体循环仅在取消有效时发出该事件,并且发生在清除队列和中止步骤之前;融合通知会隔离失败,因此损坏的监听器不能否决取消。目标驱动器利用该边沿清除预留并暂停 active 且 armed 的目标,之后循环才销毁排队工作证据。 +广义取消此前只在队列已清除或请求已中止后暴露结果。公共 agent 词汇现在新增只观察的 `agent/cancel-requested(agent, reason)`。具体循环仅在取消有效时发出该事件,并且发生在清除队列和中止步骤之前;融合通知会隔离失败,因此损坏的监听器不能否决取消。目标驱动器利用该边沿在循环销毁排队工作证据前清除预留。若该预留是排队中或已接纳的目标尝试,取消会持久暂停目标;若取消属于没有目标尝试的无关人类工作,则只移除进程内激活态。若暂停变更抛错,驱动器会回退到解除激活,避免已取消的自动工作重新启动。 该通知是协调事件,不是第二个停止 API。`Agent.cancel()` 仍是唯一的公共广义取消动词,空闲调用仍是无操作;若消费者依赖此接缝,自定义 `Agent` 实现就必须满足该事件顺序。 @@ -68,7 +68,7 @@ Status: implemented ## 测试 -单元测试使用真实 agent loop 与会话服务,只对模型编写脚本。覆盖内容包括精确连续接纳和上限执行、加载与恢复的惰性、所有结果分类、限流、请求错误、最大 token、下游提示词否决、接纳前与执行中取消、人类输入排序、排队时与下游修订竞争、伪造目标来源、变更与轮次检查点失败、调度器与自定义 agent 失败、会话启动重置、精确生命周期退出,以及排队中和运行中的插件卸载。新驱动器源码达到逐文件 100% 语句、分支、函数和行覆盖率。 +单元测试使用真实 agent loop 与会话服务,只对模型编写脚本。覆盖内容包括精确连续接纳和上限执行、加载与恢复的惰性、所有结果分类、限流、请求错误、最大 token、下游提示词否决、接纳前与执行中取消、无关人类工作取消、暂停失败回退、人类输入排序、排队时与下游修订竞争、伪造目标来源、变更与轮次检查点失败(包括后续一次性注入)、调度器与自定义 agent 失败、会话启动重置、精确生命周期退出,以及排队中和运行中的插件卸载。新驱动器源码达到逐文件 100% 语句、分支、函数和行覆盖率。 无密钥 Loader/stdio 进程测试通过 `cordis.yml` 挂载真实目标领域、目标工具、目标驱动器、agent loop、持久化和确定性适配器。一个人类轮次创建两回合目标;第一回合正常停止;第二回合读取精确引用并完成目标。测试从外部检查 JSONL,证明只有一个会话、回合来源依次为 `1, 2`、回合修订号不变、最终完成修订正确、共有五个模型步骤,并且终止性的完成工具后没有额外请求。 diff --git a/packages/goal/goal-session/README.md b/packages/goal/goal-session/README.md index d962e84029..0f3f0861b6 100644 --- a/packages/goal/goal-session/README.md +++ b/packages/goal/goal-session/README.md @@ -30,7 +30,8 @@ The retained prompt names the JSON-quoted objective and `round/maxGoalRounds`, t | Durable turn outcome | Goal action | Automatic retry | |---|---|---| | `completed` with goal still active and armed | admit the next round, or mark `budget-limited` at the cap | yes | -| broad cancellation / `aborted` | `paused` | no | +| cancellation of a reserved/admitted goal round, or its `aborted` outcome | `paused` | no | +| cancellation with no goal-round attempt | keep durable phase; disarm activation | no | | `error` with `RATE_LIMIT` | `usage-limited` | no | | other `error`, `max-tokens`, or a non-stale prompt rejection | `blocked` | no | | durability failure, disposal, interruption, or unknown future outcome | disarm or block for inspection | no | @@ -39,11 +40,11 @@ A goal mutation made during its round supersedes settlement of the older revisio ## Lifecycle and durability -`goal/changed` creates a durability obligation. Before queuing work, the driver awaits `ctx.sessions.flush()` and rechecks both the goal revision and competing input after the await. A closing flush failure arrives through `agent/error`; the driver disarms before another round can start. +`goal/changed` creates a durability obligation. Before queuing work, the driver awaits `ctx.sessions.flush()` and rechecks both the goal revision and competing input after the await. A closing flush failure arrives through `agent/error`; the driver associates it with the exact closed turn even if a later one-shot injection has appended another turn, then disarms before another round can start. Activation is never inherited when this plugin loads over an existing agent. `GoalService.disarm()` removes process-local authority without changing durable phase, revision, or history; explicit human-authorized resume records the later reactivation. The same rule applies after session resume and fork through the goal domain's `agent/session-start` handling. -Cancellation is observe-before-act: the concrete loop emits `agent/cancel-requested` before clearing queues or aborting a step, allowing this plugin to pause and disarm the exact active goal. Plugin teardown closes admission, disarms every live goal, cancels an admitted round, and awaits the driver plus agent quiescence while its event fence remains installed. +Cancellation is observe-before-act: the concrete loop emits `agent/cancel-requested` before clearing queues or aborting a step. The plugin durably pauses an active goal only when the cancellation owns a reserved or admitted goal attempt; cancellation of unrelated human work merely disarms process-local continuation. If the pause mutation fails, the driver falls back to disarming. Plugin teardown closes admission, disarms every live goal, cancels an admitted round, and awaits the driver plus agent quiescence while its event fence remains installed. ## Model Experience diff --git a/packages/goal/goal-session/src/index.ts b/packages/goal/goal-session/src/index.ts index 09ff85cac4..524bd5160a 100644 --- a/packages/goal/goal-session/src/index.ts +++ b/packages/goal/goal-session/src/index.ts @@ -276,8 +276,8 @@ export function apply(ctx: Context): void { /** Mark a post-turn persistence failure before idle scheduling can run. */ ctx.on('agent/error', (agent, turn) => { const state = stateFor(agent) - const last = agent.session.events.at(-1) - if (last?.type !== 'turn/end' || last.data.turn !== turn) return + const closed = agent.session.events.some(event => event.type === 'turn/end' && event.data.turn === turn) + if (!closed) return if (state.attempt?.turn === turn) state.flushFailedTurns.add(turn) disarm(state) }) @@ -312,11 +312,21 @@ export function apply(ctx: Context): void { }) ctx.on('agent/cancel-requested', (agent, reason) => { const state = stateFor(agent) + const attempt = state.attempt state.attempt = undefined state.competingQueued = false const goal = currentGoal(state) if (goal?.phase === 'active' && goal.activation === 'armed') { - applyOutcome(state, goal, { kind: 'pause', reason }) + if (attempt === undefined) { + disarm(state) + return + } + try { + applyOutcome(state, goal, { kind: 'pause', reason }) + } catch (error: unknown) { + ctx.logger.warn(`goal-session: could not pause cancelled goal for agent "${agent.id}": ${renderThrown(error)}`) + disarm(state) + } } }) ctx.on('goal/changed', (agent) => { diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index 13d6d9bf3f..a1ff54901a 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -402,12 +402,17 @@ describe('same-session goal driving', () => { expect(test.adapter.requests).toHaveLength(0) }) - it('disarms an admitted round whose closing durability checkpoint fails', async () => { + it('disarms an admitted round when a later injection hides its failed closing checkpoint', async () => { const test = await harness([textResponse('not durable')]) + let injected = false test.ctx.on('session/flush', (session) => { const lastStart = session.events.findLast(event => event.type === 'turn/start') if (lastStart?.type === 'turn/start' && lastStart.data.trigger.kind === 'message' - && lastStart.data.trigger.source.kind === 'goal') { + && lastStart.data.trigger.source.kind === 'goal' && !injected) { + injected = true + test.agent.inject([{ type: 'text', text: 'concurrent completion notice' }], { + source: { kind: 'plugin', plugin: 'test' }, + }) return Promise.reject(new Error('round flush failed')) } }) @@ -421,6 +426,10 @@ describe('same-session goal driving', () => { expect(goal?.phase).toBe('active') expect(test.adapter.requests).toHaveLength(1) + const turns = test.agent.session.events.filter(event => event.type === 'turn/start') + const goalTurn = turns.findIndex(event => event.data.trigger.source.kind === 'goal') + const injectedTurn = turns.findIndex(event => event.data.trigger.source.kind === 'plugin') + expect(injectedTurn).toBeGreaterThan(goalTurn) }) it('blocks the goal when a custom agent rejects the otherwise valid send', async () => { @@ -559,6 +568,42 @@ describe('same-session goal driving', () => { expect(test.adapter.requests).toHaveLength(0) }) + it('disarms without durably pausing when cancellation belongs to unrelated human work', async () => { + const test = await harness(['hang']) + test.agent.send([{ type: 'text', text: 'inspect something first' }]) + await waitForRequests(test.adapter, 1) + const created = test.ctx.goals.create(test.agent, { objective: 'continue after inspection' }) + + test.agent.cancel('cancel the inspection') + await test.agent.whenIdle() + + expect(test.ctx.goals.get(test.agent)).toMatchObject({ + id: created.id, + revision: created.revision, + phase: 'active', + activation: 'disarmed', + roundsStarted: 0, + }) + }) + + it('falls back to disarming when a cancelled reservation cannot be paused', async () => { + const test = await harness([]) + const cancel = test.ctx.on('agent/queued', (agent, _content, info) => { + if (agent !== test.agent || info.source.kind !== 'goal') return + cancel() + vi.spyOn(test.ctx.goals, 'pause').mockImplementationOnce(() => { + throw new Error('pause failed') + }) + agent.cancel('cancel the reserved goal round') + }) + test.ctx.goals.create(test.agent, { objective: 'fail closed after cancellation' }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.activation === 'disarmed') + + expect(goal).toMatchObject({ phase: 'active', revision: 1, roundsStarted: 0 }) + expect(test.adapter.requests).toHaveLength(0) + }) + it('blocks admission when downstream cancellation clears the reservation', async () => { const test = await harness([]) let cancelled = false From 6f4f459c73b336d0b82ba88d7e3b895e345f697b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:30:51 +0800 Subject: [PATCH 10/25] test(goal): cover terminal update validation --- packages/goal/tool-goal/tests/tool-goal.spec.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 3950bf1e63..044fe2369a 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -384,6 +384,13 @@ describe('goal tool state transitions', () => { objective: 'not valid for pause', }, root.agent) expect(replacement.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + const terminalUpdate = await execute(ctx, 'update_goal', { + goal_id: created.id, + revision: created.revision, + action: 'complete', + max_goal_rounds: 2, + }, root.agent) + expect(terminalUpdate.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') const malformedRef = await execute(ctx, 'update_goal', { goal_id: '', revision: 0, action: 'edit', objective: 'x', }, root.agent) From 2dd3b1dfba6921216ba7d0168a15ac277a4e4cd8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:24:02 +0800 Subject: [PATCH 11/25] fix(commands): harden registry and UI ordering --- ...7-19-plugin-command-registration.i18n.yaml | 4 +- .../2026-07-19-plugin-command-registration.md | 6 +- ...26-07-19-plugin-command-registration.zh.md | 6 +- docs/config-catalog.md | 2 +- docs/cordis-catalog/events.md | 5 +- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 2 +- .../advanced-toolchain/stdout.expected.jsonl | 2 +- .../bash-spill/stdout.expected.jsonl | 2 +- .../both-mode-turn/stdout.expected.jsonl | 2 +- .../cancel-tool-calls/stdout.expected.jsonl | 2 +- .../snapshots/cancel/stdout.expected.jsonl | 2 +- .../code-mode-turn/stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../config-options/stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../error-finish/stdout.expected.jsonl | 2 +- .../escalation-approved/stdout.expected.jsonl | 2 +- .../escalation-rejected/stdout.expected.jsonl | 2 +- .../snapshots/fs-edit/stdout.expected.jsonl | 2 +- .../fs-policy-reject/stdout.expected.jsonl | 2 +- .../fs-read-window/stdout.expected.jsonl | 2 +- .../snapshots/fs-read/stdout.expected.jsonl | 2 +- .../fs-terminal-card/stdout.expected.jsonl | 2 +- .../fs-write-overwrite/stdout.expected.jsonl | 2 +- .../snapshots/fs-write/stdout.expected.jsonl | 2 +- .../snapshots/handshake/stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../hook-cc-pretool-ask/stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../model-switching/stdout.expected.jsonl | 2 +- .../multi-turn/stdout.expected.jsonl | 2 +- .../parallel-tool-calls/stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../repeat-tool-guard/stdout.expected.jsonl | 2 +- .../skill-load/stdout.expected.jsonl | 2 +- .../subagent-fork/stdout.expected.jsonl | 2 +- .../subagent-mixed/stdout.expected.jsonl | 2 +- .../subagent-multi/stdout.expected.jsonl | 2 +- .../subagent-spawn/stdout.expected.jsonl | 2 +- .../snapshots/text-turn/stdout.expected.jsonl | 2 +- .../snapshots/todo-plan/stdout.expected.jsonl | 2 +- .../tool-call-turn/stdout.expected.jsonl | 2 +- .../workflow-run/stdout.expected.jsonl | 2 +- .../workspace-context/stdout.expected.jsonl | 2 +- .../workspace-edit/stdout.expected.jsonl | 2 +- packages/ui/acp/README.md | 4 +- packages/ui/acp/src/index.ts | 59 +++++++++++++++++-- packages/ui/acp/tests/commands.spec.ts | 33 ++++++++--- packages/ui/acp/tests/edges.spec.ts | 5 +- packages/ui/commands/README.md | 2 +- packages/ui/commands/src/index.ts | 52 +++++++++++++--- packages/ui/commands/tests/commands.spec.ts | 53 +++++++++++++++-- packages/ui/tui/src/index.ts | 8 ++- packages/ui/tui/tests/tui.spec.ts | 29 +++++++++ website/zh-CN/api/harness/commands.md | 10 ++-- website/zh-CN/api/harness/events.md | 5 +- 67 files changed, 284 insertions(+), 101 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml index e75c1f020f..48d880e666 100644 --- a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml @@ -2,5 +2,5 @@ # 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: # pnpm run verify-translation-pairing --write -2026-07-19-plugin-command-registration.md: 5b599358036054c644985474c9c16518fcb5042a -2026-07-19-plugin-command-registration.zh.md: 08c9e13d122bc56c9feae35244e0f903c3b76b32 +2026-07-19-plugin-command-registration.md: 821f22405fd6a4bc0b8bfd3c5edf773be844899d +2026-07-19-plugin-command-registration.zh.md: 7a2ed82eb1a8f55d4d701a68c4053e5d412b7d04 diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md index 5b59935803..821f22405f 100644 --- a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md +++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md @@ -26,7 +26,7 @@ A `CommandDefinition` contains a lowercase name without `/`, a non-empty descrip An unscoped registration is global. A command-injected plugin mounted beneath an agent context inherits that agent's scope key and lifetime, so its definition shadows a same-named global only for that exact agent. The child declares its own `commands` injection because `agent.ctx` intentionally inherits the core agent-loop dependency surface; adding a UI service to the loop merely to enable scoped registration would invert the dependency graph. -Registration and removal emit the unfiltered `commands/change` registry notification. Adapters recompute each live agent's effective view rather than trying to infer which sessions a change affects. Cordis ownership removes definitions when their producer, UI instance, or agent scope unloads, so HMR cannot leave stale discovery entries or handlers. +Registration and removal emit the unfiltered, non-vetoing `commands/change` registry notification. Adapters recompute each live agent's effective view rather than trying to infer which sessions a change affects. The registry contains and logs each observer failure independently, so a broken UI refresh cannot roll back another plugin's mutation or starve a later observer. Cordis ownership removes definitions when their producer, UI instance, or agent scope unloads, so HMR cannot leave stale discovery entries or handlers. ### Direct dispatch and cancellation @@ -42,7 +42,7 @@ Each submitted command owns an `AbortController`. TUI disposal aborts outstandin ### ACP mapping -The bridge follows the current [ACP v1 slash-command contract](https://agentclientprotocol.com/protocol/v1/slash-commands). `session/new` and `session/load` emit the exact agent's full `available_commands_update` snapshot; every registry change emits a replacement snapshot for each live session. Names, descriptions, and optional unstructured-input hints map directly to `AvailableCommand`. +The bridge follows the current [ACP v1 slash-command contract](https://agentclientprotocol.com/protocol/v1/slash-commands). `session/new` and `session/load` emit the exact agent's full `available_commands_update` snapshot; a new session's RPC response introduces its server-generated id before the snapshot is enqueued. Every registry change emits a replacement snapshot for each live session. Names, descriptions, and optional unstructured-input hints map directly to `AvailableCommand`. ACP permits a command prompt to contain additional supported content blocks. The bridge applies its ordinary lossless `text` and `resource_link` flattening, then enters the command plane when the result starts with `/`. Unsupported prompt blocks are rejected by the existing capability boundary. Known commands execute directly; unknown or malformed slash input returns a direct error and never reaches the model. Successful text, expected errors, and thrown-failure diagnostics stream as live `agent_message_chunk` output and settle `end_turn`. @@ -50,7 +50,7 @@ One model prompt or direct command may be in flight per ACP session, independent ## Testing -The registry suite covers syntax boundaries, immutable normalization, default and explicit surfaces, deterministic sorting, global and scoped shadowing, duplicate rejection, exact disposal, change-notification rollback, direct invocation, expected and malformed results, synchronous and asynchronous failure, and every abort timing edge at per-file 100% statement, branch, function, and line coverage. +The registry suite covers syntax boundaries, immutable normalization, runtime metadata validation, default and explicit surfaces, deterministic sorting, global and scoped shadowing, duplicate rejection, exact disposal, contained change-notification failures, direct invocation, expected and malformed results, synchronous and asynchronous failure, and every abort timing edge at per-file 100% statement, branch, function, and line coverage. TUI tests exercise all migrated built-ins, live plugin discovery, help/autocomplete refresh, direct results, unknown-command rejection, raw-input delivery, definition removal, startup rollback, and disposal cancellation. ACP tests use the real SDK connection, agent factory, loop, and JSONL persistence to verify create/load snapshots, dynamic updates, scoped multi-session catalogs, supported-block flattening, direct success/error/failure, unknown-command isolation, cancellation, and the absence of model requests or session messages. The SDK helper suite pins direct-ACP composition. Keyless ACP and terminal snapshots pin the new protocol and rendered transcript shapes. diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md index 08c9e13d12..7a2ed82eb1 100644 --- a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md @@ -26,7 +26,7 @@ TUI 拥有七个斜杠命令,而 ACP 定义了标准命令目录与调用形 无作用域注册是全局注册。挂载在智能体上下文之下并注入 `commands` 的插件会继承该智能体的作用域键与生命周期,因此其定义仅为该准确智能体遮蔽同名全局定义。子插件自行声明 `commands` 注入,因为 `agent.ctx` 有意只继承核心智能体循环的依赖界面;仅为了实现作用域注册而让循环依赖 UI 服务会倒置依赖图。 -注册和移除会发出未过滤的 `commands/change` 注册表通知。适配器重新计算每个实时智能体的有效视图,而不尝试推断某次变更影响哪些会话。Cordis 所有权会在生产者、UI 实例或智能体作用域卸载时移除定义,因此 HMR 不会留下陈旧的发现项或处理器。 +注册和移除会发出未过滤、不可否决的 `commands/change` 注册表通知。适配器重新计算每个实时智能体的有效视图,而不尝试推断某次变更影响哪些会话。注册表会分别隔离并记录每个观察者失败,因此损坏的 UI 刷新无法回滚另一插件的变更,也无法阻止后续观察者。Cordis 所有权会在生产者、UI 实例或智能体作用域卸载时移除定义,因此 HMR 不会留下陈旧的发现项或处理器。 ### 直接分派与取消 @@ -42,7 +42,7 @@ TUI 把 `help`、`clear`、`cancel`、`reasoning`、`tools`、`redraw` 和 `exit ### ACP 映射 -桥接遵循当前的 [ACP v1 斜杠命令契约](https://agentclientprotocol.com/protocol/v1/slash-commands)。`session/new` 与 `session/load` 发出准确智能体的完整 `available_commands_update` 快照;每次注册表变更都会为每个实时会话发出替换快照。名称、描述和可选非结构化输入提示直接映射到 `AvailableCommand`。 +桥接遵循当前的 [ACP v1 斜杠命令契约](https://agentclientprotocol.com/protocol/v1/slash-commands)。`session/new` 与 `session/load` 发出准确智能体的完整 `available_commands_update` 快照;新会话的 RPC 响应会先引入服务端生成的 id,随后快照才会入队。每次注册表变更都会为每个实时会话发出替换快照。名称、描述和可选非结构化输入提示直接映射到 `AvailableCommand`。 ACP 允许命令提示携带额外的受支持内容块。桥接应用普通的无损 `text` 与 `resource_link` 扁平化,然后在结果以 `/` 开头时进入命令平面。不支持的提示块由现有能力边界拒绝。已知命令直接执行;未知或格式错误的斜杠输入返回直接错误,绝不会到达模型。成功文本、预期错误和抛出失败的诊断作为实时 `agent_message_chunk` 输出流式发送,并以 `end_turn` 结束请求。 @@ -50,7 +50,7 @@ ACP 允许命令提示携带额外的受支持内容块。桥接应用普通的 ## 测试 -注册表测试覆盖语法边界、不可变规范化、默认和显式界面、确定性排序、全局与作用域遮蔽、重复拒绝、准确释放、变更通知回滚、直接调用、预期和格式错误结果、同步与异步失败,以及每种中止时序边沿;该源文件达到逐文件 100% 语句、分支、函数和行覆盖率。 +注册表测试覆盖语法边界、不可变规范化、运行时元数据校验、默认和显式界面、确定性排序、全局与作用域遮蔽、重复拒绝、准确释放、变更通知失败隔离、直接调用、预期和格式错误结果、同步与异步失败,以及每种中止时序边沿;该源文件达到逐文件 100% 语句、分支、函数和行覆盖率。 TUI 测试覆盖全部迁移后的内置命令、实时插件发现、帮助与自动补全刷新、直接结果、未知命令拒绝、原始输入交付、定义移除、启动回滚和释放取消。ACP 测试使用真实 SDK 连接、智能体工厂、循环与 JSONL 持久化,验证创建/加载快照、动态更新、作用域多会话目录、受支持块扁平化、直接成功/错误/失败、未知命令隔离、取消,以及不存在模型请求或会话消息。SDK helper 测试固定直接 ACP 组合。无密钥 ACP 与终端快照固定新的协议和渲染记录形态。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d40c155123..5a46c699e5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -27,7 +27,7 @@ export interface AcpConfig { Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/ui/acp/src/index.ts:217`](../packages/ui/acp/src/index.ts) +Source: [`packages/ui/acp/src/index.ts:246`](../packages/ui/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-demo` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 4c67788d82..3d23d95105 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -414,18 +414,19 @@ Source: [`packages/ui/user-approval/src/index.ts:31`](../../packages/ui/user-app ### `commands/change` — emit -A command was registered or unregistered. This is an unfiltered registry notification because a global or scoped change may affect any UI view. +A command was registered or unregistered. This is an unfiltered registry notification because a global or scoped change may affect any UI view. Observer failures are contained and cannot veto the registry mutation. ```ts cordis-catalog /** * A command was registered or unregistered. This is an unfiltered registry * notification because a global or scoped change may affect any UI view. + * Observer failures are contained and cannot veto the registry mutation. * @mode emit */ 'commands/change'(): void ``` -Source: [`packages/ui/commands/src/index.ts:93`](../../packages/ui/commands/src/index.ts) +Source: [`packages/ui/commands/src/index.ts:94`](../../packages/ui/commands/src/index.ts) ## `fs/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index adff7993e5..113e4cc62d 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -382,7 +382,7 @@ async execute( agent: Agent, surface: CommandSurface, line: string, signal: Abor Types: [Agent](../core-data-structures/core.md) · [CommandDefinition](../core-data-structures/commands.md) · [CommandDescriptor](../core-data-structures/commands.md) · [CommandResult](../core-data-structures/commands.md) · [CommandSurface](../core-data-structures/commands.md) -Source: [`packages/ui/commands/src/index.ts:216`](../../packages/ui/commands/src/index.ts) +Source: [`packages/ui/commands/src/index.ts:235`](../../packages/ui/commands/src/index.ts) ## `ctx.compact` — `CompactService` (abstract seam) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b949abcb03..dfa2d1f11b 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -25,7 +25,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:302`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:312`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | -| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:93`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`emit`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | +| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:94`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:53`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl index 50d688089e..6410d07df3 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-mount","title":"Mount plugin into live cordis runtime","kind":"execute","status":"in_progress","rawInput":{"code":"return { name: 'snapshot-marker', apply() {} }"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"advanced-mount","status":"completed","content":[{"type":"content","content":{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-code","title":"return await tools.cordis_inspect({ what: 'dynamic' })","kind":"execute","status":"in_progress","rawInput":"return await tools.cordis_inspect({ what: 'dynamic' })"}}} diff --git a/examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl index 875ff05303..739deae606 100644 --- a/examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_spill","title":"node -e \"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\"","kind":"execute","status":"in_progress","rawInput":"node -e \"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\"","content":[{"type":"content","content":{"type":"text","text":"Print large deterministic output"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_spill","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\nSPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: {{spillLocator:bash.txt}}. Use read with offset/limit, or grep this path to search within it.)\n```"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl index 6c6e1d6158..d6964687d1 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl index 1e1131398b..4d770492c5 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_wait","title":"node -e \"setInterval(() => {}, 1000)\"","kind":"execute","status":"in_progress","rawInput":"node -e \"setInterval(() => {}, 1000)\"","content":[{"type":"content","content":{"type":"text","text":"Wait until cancellation"}}]}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_wait","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\nError: command aborted\n```"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl index 0277ef2f90..c17327002f 100644 --- a/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"partial"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl index 2e06d6839a..003205b364 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl index a41a9b8b3b..043607a926 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/config-options/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/config-options/stdout.expected.jsonl index 4e2f95accf..eb9762f95a 100644 --- a/examples/acp-agent/tests/snapshots/config-options/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/config-options/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":4,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":5,"error":{"code":-32602,"message":"Invalid params: unknown permission value \"plan\""}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl index 50c05a4d18..e8b173bff2 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"Inspect cordis runtime: api: tools","kind":"read","status":"in_progress"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n }\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export type ContextEnvelope = 'context' | 'raw';\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-event","title":"Inspect cordis runtime: events: tools/pre-execute","kind":"read","status":"in_progress"}}} diff --git a/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl index 80ab62b674..928ce6e2c9 100644 --- a/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl @@ -1,4 +1,4 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":3,"error":{"code":-32603,"message":"Internal error: turn failed: simulated provider error (HTTP 401)"}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl index e169016a1c..de4c76482c 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl index 5a60174abb..e99bb3a5bb 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl index 451fe781dd..40223acda3 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl index 39ddf764de..635a185739 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl index 2f2e1bc667..c2dcf2a731 100644 --- a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl index 3fed936cfc..4ce2099ace 100644 --- a/examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.expected.jsonl index 51bb5f7c65..3244ee21c3 100644 --- a/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl index 9e0d057b2a..f8c7edd015 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl index 927bb99975..e2e3bef55d 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl index 1390e424d6..dab4affa7e 100644 --- a/examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl @@ -1,3 +1,3 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl index 89e476b3e8..653a7340f8 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl index b13a498bc0..2aa177932e 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl index 5acbeb7202..262ce3e2ea 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl index 623127e083..094e78be67 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl index 19ec84b738..a6accd8f18 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl @@ -1,4 +1,4 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl index 3f75a15f57..44a5da1302 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'s"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl index bf478888b4..aad4e1a5b1 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl index b8b6abe2c3..05fc598709 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl index c8c34ab6ec..bc4266cc04 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl index 8e303806a9..73e123a566 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl index 19ec84b738..a6accd8f18 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl @@ -1,4 +1,4 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl index 6107da23da..2c602e4cc1 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl index ef02915d01..96df6e2ef4 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/model-switching/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/model-switching/stdout.expected.jsonl index e3a5aacbeb..e50144c03e 100644 --- a/examples/acp-agent/tests/snapshots/model-switching/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/model-switching/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl index 57dd33f320..a9da0b4a4f 100644 --- a/examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl index 53048fa11d..946a91f75a 100644 --- a/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_read_a","title":"Read a.txt","kind":"read","status":"in_progress","locations":[{"path":"a.txt","line":1}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_read_b","title":"Read b.txt","kind":"read","status":"in_progress","locations":[{"path":"b.txt","line":1}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_read_a","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/permission-switching/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/permission-switching/stdout.expected.jsonl index 2232cd3a1c..53197fe328 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/permission-switching/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.expected.jsonl index bc40beb3fe..f1c2fd0634 100644 --- a/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_1","title":"Update todo list","kind":"other","status":"in_progress","rawInput":[{"content":"watch the kettle boil","status":"in_progress"}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"plan","entries":[{"content":"watch the kettle boil","priority":"medium","status":"in_progress"}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}}]}}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl index 8436971982..3e17783db5 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Load the requested skill."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_skill_load","title":"Load skill snapshot-skill","kind":"read","status":"in_progress","rawInput":"snapshot-skill"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_skill_load","status":"completed","content":[{"type":"content","content":{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/snapshot-skill\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\nFollow these snapshot-only instructions.\nResolve referenced resources relative to this skill directory.\n\n"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl index a332ea7a7c..dd21b9674f 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl index ded1ec01cc..7bfd214919 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl index d25e78d0db..4916b87bc8 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl index d1f79bacac..22e034b251 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl index dc7b4dbe25..6171c640e4 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/todo-plan/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/todo-plan/stdout.expected.jsonl index 0cae765592..ee40cae2fd 100644 --- a/examples/acp-agent/tests/snapshots/todo-plan/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/todo-plan/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl index dce9237834..9183664a0f 100644 --- a/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl index 73604add34..24333f95a0 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/stdout.expected.jsonl index 8108f47bb0..3b233174fc 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_workspace_read","title":"Read nested/task.txt","kind":"read","status":"in_progress","locations":[{"path":"nested/task.txt","line":1}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_workspace_read","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl index ff2610f1f5..38c5dcdfc1 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index 3e74236f81..d74a0fc573 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -41,9 +41,9 @@ One id-keyed record map plus exact agent-object checks route every event, prompt ## Human commands -After `session/new` and `session/load`, the bridge emits ACP's full `available_commands_update` snapshot for that exact agent. A global or scoped registry change refreshes every live session from its independently resolved view, so clients replace rather than merge cached catalogs. Names omit the slash; descriptions and optional unstructured-input hints map directly to ACP `AvailableCommand`. +After `session/new` and `session/load`, the bridge emits ACP's full `available_commands_update` snapshot for that exact agent. A new session's server-generated id is introduced by the RPC response before its snapshot enters the connection write queue. A global or scoped registry change refreshes every live session from its independently resolved view, so clients replace rather than merge cached catalogs. Names omit the slash; descriptions and optional unstructured-input hints map directly to ACP `AvailableCommand`. -ACP v1 permits a command prompt to carry additional content blocks. The bridge applies its ordinary lossless flattening for supported `text` and `resource_link` blocks, then dispatches when the result begins with `/`. Known commands execute without a model request. Unknown or malformed slash input returns a direct error instead of falling back to the model. Expected handler errors, thrown failures, and successful text stream as UI-only `agent_message_chunk` output and end the request; cancellation returns `cancelled`. See the [command Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) and the [ACP v1 slash-command contract](https://agentclientprotocol.com/protocol/v1/slash-commands). +ACP v1 permits a command prompt to carry additional content blocks. The bridge applies its ordinary lossless flattening for supported `text` and `resource_link` blocks, then dispatches when the result begins with `/`. Known commands execute without a model request. Unknown or malformed slash input returns a direct error instead of falling back to the model; prefix whitespace when literal slash-leading text must reach the model. Expected handler errors, thrown failures, and successful text stream as UI-only `agent_message_chunk` output and end the request; cancellation returns `cancelled`. See the [command Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) and the [ACP v1 slash-command contract](https://agentclientprotocol.com/protocol/v1/slash-commands). ## Session config options diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 3d4c060060..459dc50a43 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -17,6 +17,7 @@ import { PROTOCOL_VERSION, RequestError, type Agent as AcpAgent, + type AnyMessage, type AuthenticateRequest, type AvailableCommand, type CancelNotification, @@ -94,6 +95,34 @@ function renderThrown(value: unknown): string { } } +/** Return a server-created session id carried by an outbound success response. */ +function responseSessionId(message: AnyMessage): SessionId | undefined { + if (!('result' in message) || typeof message.result !== 'object' || message.result === null + || !('sessionId' in message.result) || typeof message.result.sessionId !== 'string') { + return undefined + } + return SessionId(message.result.sessionId) +} + +/** Observe messages only after the wrapped ACP transport has written them. */ +function observeOutbound(stream: Stream, onWritten: (message: AnyMessage) => void): Stream { + const writer = stream.writable.getWriter() + return { + readable: stream.readable, + writable: new WritableStream({ + async write(message) { + await writer.write(message) + onWritten(message) + }, + /* v8 ignore start -- the ACP SDK never closes or aborts its outbound stream; + preserve the wrapped Stream contract for other consumers nonetheless */ + close: () => writer.close(), + abort: (reason: unknown) => writer.abort(reason), + /* v8 ignore stop */ + }), + } +} + /** Preserve failed-turn detail; plain handler errors become a generic wire internal error. */ function internalError(detail: string): RequestError { return RequestError.internalError(undefined, detail) @@ -393,6 +422,9 @@ export function apply(ctx: Context, config: AcpConfig): void { const sessions = new Map() // Reserve an id before resume so pipelined load/new requests cannot duplicate it. const loadingIds = new Set() + // A new-session response introduces its server-generated id to the client; + // keep its initial command snapshot pending until that response is written. + const pendingCommandSnapshots = new Map() // Async creation checks this after awaits to avoid publishing after teardown. let closed = false // Each new or loaded session snapshots the latest connection capability. @@ -499,10 +531,23 @@ export function apply(ctx: Context, config: AcpConfig): void { }) } + /** Enqueue a new session's first command snapshot behind its written RPC response. */ + const announceInitialCommands = (message: AnyMessage): void => { + const sessionId = responseSessionId(message) + if (sessionId === undefined) return + const rec = pendingCommandSnapshots.get(sessionId) + if (rec === undefined) return + pendingCommandSnapshots.delete(sessionId) + notifyCommands(rec) + } + // Registration and HMR removal can affect global or one scoped view; refresh - // every bridge-owned session and let the registry resolve each exact agent. + // every announced bridge-owned session and let the registry resolve each + // exact agent. A pending new-session snapshot will read the latest registry. ctx.on('commands/change', () => { - for (const rec of sessions.values()) notifyCommands(rec) + for (const rec of sessions.values()) { + if (!pendingCommandSnapshots.has(rec.agent.session.id)) notifyCommands(rec) + } }) /** Settle the in-flight prompt with a stop reason, exactly once (no-op if none pending). */ @@ -711,7 +756,7 @@ export function apply(ctx: Context, config: AcpConfig): void { await handle.dispose() throw internalError('connection closed during session/new') } - sessions.set(sessionId, { + const record: SessionRecord = { agent: handle.agent, dispose: () => handle.dispose(), presenter: makePresenter(handle.agent), @@ -720,8 +765,9 @@ export function apply(ctx: Context, config: AcpConfig): void { inflight: undefined, commandAbort: undefined, pendingSwitches: {}, - }) - notifyCommands(requireSession(sessionId)) + } + sessions.set(sessionId, record) + pendingCommandSnapshots.set(sessionId, record) const configOptions = configOptionsFor(handle.agent, directory) return { sessionId, ...configOptions.length > 0 ? { configOptions } : {} } }, @@ -998,7 +1044,7 @@ export function apply(ctx: Context, config: AcpConfig): void { Writable.toWeb(process.stdout) as WritableStream, Readable.toWeb(process.stdin) as ReadableStream, ) - conn = new AgentSideConnection(makeAgent, stream) + conn = new AgentSideConnection(makeAgent, observeOutbound(stream, announceInitialCommands)) /** * Tear ALL live sessions down to quiescence (docs/defensive-patterns.md "dispose must reach @@ -1036,6 +1082,7 @@ export function apply(ctx: Context, config: AcpConfig): void { // installed yet) must observe this after its await and refuse to install a // post-teardown record. Set even when there are no live sessions. closed = true + pendingCommandSnapshots.clear() const recs = [...sessions.values()] sessions.clear() if (recs.length === 0) return Promise.resolve() diff --git a/packages/ui/acp/tests/commands.spec.ts b/packages/ui/acp/tests/commands.spec.ts index 487b4b59f0..946ee67950 100644 --- a/packages/ui/acp/tests/commands.spec.ts +++ b/packages/ui/acp/tests/commands.spec.ts @@ -41,13 +41,15 @@ describe('ACP plugin commands', () => { await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - expect(commandUpdates(harness, sessionId).at(-1)?.update).toEqual({ - sessionUpdate: 'available_commands_update', - availableCommands: [{ - name: 'inspect', - description: 'Inspect the session', - input: { hint: '' }, - }], + await vi.waitFor(() => { + expect(commandUpdates(harness!, sessionId).at(-1)?.update).toEqual({ + sessionUpdate: 'available_commands_update', + availableCommands: [{ + name: 'inspect', + description: 'Inspect the session', + input: { hint: '' }, + }], + }) }) const dispose = harness.ctx.commands.register({ @@ -88,6 +90,23 @@ describe('ACP plugin commands', () => { }) }) + it('coalesces registry changes before a new session command snapshot is announced', async () => { + harness = await makeBridgeHarness({ storageDir }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + harness.ctx.commands.register({ + name: 'raced', description: 'Registered after the response', handler: () => ({ kind: 'success' }), + }) + + await vi.waitFor(() => { + expect(commandUpdates(harness!, sessionId)).toHaveLength(1) + expect(commandUpdates(harness!, sessionId)[0]?.update).toMatchObject({ + availableCommands: [{ name: 'raced' }], + }) + }) + }) + it('executes a known single-text command directly and never sends it to the model', async () => { harness = await makeBridgeHarness({ storageDir }) const seen = vi.fn(() => ({ kind: 'success' as const, text: 'DIRECT RESULT' })) diff --git a/packages/ui/acp/tests/edges.spec.ts b/packages/ui/acp/tests/edges.spec.ts index 35dd54a634..fdbaf241e6 100644 --- a/packages/ui/acp/tests/edges.spec.ts +++ b/packages/ui/acp/tests/edges.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -24,6 +24,9 @@ describe('acp bridge — demux & config edges', () => { harness = await makeBridgeHarness({ storageDir, script: [textResponse('foreign')] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await vi.waitFor(() => { + expect(harness!.updates.some(update => update.sessionUpdate === 'available_commands_update')).toBe(true) + }) const before = harness.updates.length const { agent: foreign } = await harness.ctx.agents.create({ sessionId: SessionId('foreign-session'), agentOptions: { provider: 'mock', model: 'mock' } }) diff --git a/packages/ui/commands/README.md b/packages/ui/commands/README.md index 2c1f358dfa..5b6c8b9425 100644 --- a/packages/ui/commands/README.md +++ b/packages/ui/commands/README.md @@ -4,7 +4,7 @@ Plugin-owned human-command registry shared by the TUI and ACP adapters. The [plu ## Service contract -`ctx.commands.register(definition)` registers one lowercase command name, description, optional ACP-compatible unstructured-input hint, optional surface list, and abortable handler. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal emits `commands/change` so live adapters can refresh discovery. +`ctx.commands.register(definition)` registers one lowercase command name, description, optional ACP-compatible unstructured-input hint, optional surface list, and abortable handler. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers. `list(agent, surface)` returns immutable, name-sorted descriptors after scoped shadowing and surface filtering. `find(agent, surface, name)` returns the corresponding definition. `execute(agent, surface, line, signal)` uses `parseCommand()` and runs only a known command, returning `undefined` for invalid syntax, unknown names, or commands hidden from that surface. diff --git a/packages/ui/commands/src/index.ts b/packages/ui/commands/src/index.ts index 97ec0c3587..529dea11b4 100644 --- a/packages/ui/commands/src/index.ts +++ b/packages/ui/commands/src/index.ts @@ -88,6 +88,7 @@ declare module 'cordis' { /** * A command was registered or unregistered. This is an unfiltered registry * notification because a global or scoped change may affect any UI view. + * Observer failures are contained and cannot veto the registry mutation. * @mode emit */ 'commands/change'(): void @@ -115,6 +116,15 @@ function abortError(signal: AbortSignal): Error { return new Error(typeof signal.reason === 'string' ? signal.reason : 'command aborted') } +/** Render arbitrary thrown values without trusting their string coercion. */ +function renderThrown(value: unknown): string { + try { + return String(value) + } catch { + return '' + } +} + /** Stop awaiting an uncooperative handler once its owning UI request aborts. */ function withAbort(promise: Promise, signal: AbortSignal): Promise { if (signal.aborted) return Promise.reject(abortError(signal)) @@ -133,7 +143,7 @@ function withAbort(promise: Promise, signal: AbortSignal): Promise { signal.removeEventListener('abort', onAbort) reject(error instanceof Error ? error - : new Error('command handler rejected with a non-Error value')) + : new Error(`command handler rejected with a non-Error value: ${renderThrown(error)}`, { cause: error })) }, ) }) @@ -144,17 +154,26 @@ function normalizeDefinition(definition: CommandDefinition): RegisteredCommand { if (!COMMAND_NAME.test(definition.name)) { throw new TypeError(`command name "${definition.name}" must match ${String(COMMAND_NAME)}`) } + if (typeof definition.description !== 'string') { + throw new TypeError(`command "${definition.name}" description must be a string`) + } if (definition.description.trim().length === 0) { throw new TypeError(`command "${definition.name}" description must not be empty`) } if (typeof definition.handler !== 'function') { throw new TypeError(`command "${definition.name}" handler must be a function`) } - const input = definition.input === undefined - ? undefined - : Object.freeze({ hint: definition.input.hint }) - if (input !== undefined && input.hint.trim().length === 0) { - throw new TypeError(`command "${definition.name}" input hint must not be empty`) + const rawInput: unknown = definition.input + let input: CommandInputDescriptor | undefined + if (rawInput !== undefined) { + if (typeof rawInput !== 'object' || rawInput === null || !('hint' in rawInput) + || typeof rawInput.hint !== 'string') { + throw new TypeError(`command "${definition.name}" input hint must be a string`) + } + if (rawInput.hint.trim().length === 0) { + throw new TypeError(`command "${definition.name}" input hint must not be empty`) + } + input = Object.freeze({ hint: rawInput.hint }) } const surfaces = [...(definition.surfaces ?? DEFAULT_SURFACES)] if (surfaces.length === 0) { @@ -240,9 +259,9 @@ export class CommandService extends Service { yield () => { layer.delete(registered.definition.name) if (scope !== undefined && layer.size === 0) this.scoped.delete(scope) - this.ctx.emit('commands/change') + this.notifyChange() } - this.ctx.emit('commands/change') + this.notifyChange() }.bind(this), 'commands.register()') // eslint-disable-next-line @typescript-eslint/no-misused-promises -- exact synchronous disposer preserves composite teardown order return dispose @@ -314,6 +333,23 @@ export class CommandService extends Service { } return layer } + + /** Notify every registry observer without making UI refresh load-bearing. */ + private notifyChange(): void { + // Cordis emit uses Array.map: one synchronous throw starves later listeners, + // and returned promises are discarded. Registry notifications are + // non-vetoing, so contain each callback independently. + for (const callback of this.ctx.events.dispatch('emit', ['commands/change'])) { + try { + const returned: unknown = callback() + void Promise.resolve(returned).catch((error: unknown) => { + this.ctx.logger.warn(`commands/change listener rejected: ${renderThrown(error)}`) + }) + } catch (error: unknown) { + this.ctx.logger.warn(`commands/change listener threw: ${renderThrown(error)}`) + } + } + } } export default CommandService diff --git a/packages/ui/commands/tests/commands.spec.ts b/packages/ui/commands/tests/commands.spec.ts index 67f9d3d0e2..e3da186492 100644 --- a/packages/ui/commands/tests/commands.spec.ts +++ b/packages/ui/commands/tests/commands.spec.ts @@ -107,7 +107,7 @@ describe('CommandService', () => { expect(() => scope.ctx.commands.register(command('same'))).toThrow(/already registered in this scope/) }) - it('emits on registration and disposal and rolls back when notification fails', async () => { + it('notifies on registration and disposal while containing broken observers', async () => { const ctx = await mount() const changed = vi.fn() ctx.on('commands/change', changed) @@ -116,11 +116,39 @@ describe('CommandService', () => { dispose() expect(changed).toHaveBeenCalledTimes(2) - const explode = ctx.on('commands/change', () => { throw new Error('observer failed') }) - expect(() => ctx.commands.register(command('rollback'))).toThrow('observer failed') - explode() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + ctx.on('commands/change', () => { throw new Error('observer threw') }) + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- exercises rejected-listener containment + ctx.on('commands/change', () => Promise.reject(new Error('observer rejected'))) + const afterFailures = vi.fn() + ctx.on('commands/change', afterFailures) + const removeContained = ctx.commands.register(command('contained')) const { agent } = await mintAgentScope(ctx, 'a') - expect(ctx.commands.find(agent, 'tui', 'rollback')).toBeUndefined() + expect(ctx.commands.find(agent, 'tui', 'contained')).toBeDefined() + expect(afterFailures).toHaveBeenCalledTimes(1) + await vi.waitFor(() => { + expect(warn).toHaveBeenCalledWith('commands/change listener threw: Error: observer threw') + expect(warn).toHaveBeenCalledWith('commands/change listener rejected: Error: observer rejected') + }) + removeContained() + expect(ctx.commands.find(agent, 'tui', 'contained')).toBeUndefined() + expect(afterFailures).toHaveBeenCalledTimes(2) + }) + + it('rejects non-string descriptions and input hints with boundary diagnostics', async () => { + const ctx = await mount() + expect(() => ctx.commands.register({ + ...command('description-type'), + description: undefined, + } as unknown as CommandDefinition)).toThrow('command "description-type" description must be a string') + expect(() => ctx.commands.register({ + ...command('hint-type'), + input: { hint: 42 }, + } as unknown as CommandDefinition)).toThrow('command "hint-type" input hint must be a string') + expect(() => ctx.commands.register({ + ...command('input-type'), + input: null, + } as unknown as CommandDefinition)).toThrow('command "input-type" input hint must be a string') }) it('passes exact invocation context and detaches valid handler results', async () => { @@ -187,7 +215,20 @@ describe('CommandService', () => { handler: () => Promise.reject('not an Error'), }) await expect(ctx.commands.execute(agent, 'tui', '/reject-value', new AbortController().signal)) - .rejects.toThrow('command handler rejected with a non-Error value') + .rejects.toThrow('command handler rejected with a non-Error value: not an Error') + + const hostile = { toString(): string { throw new Error('cannot render') } } + ctx.commands.register({ + name: 'reject-hostile', + description: 'Reject an unrenderable value', + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise hostile plugin normalization + handler: () => Promise.reject(hostile), + }) + await expect(ctx.commands.execute(agent, 'tui', '/reject-hostile', new AbortController().signal)) + .rejects.toMatchObject({ + message: 'command handler rejected with a non-Error value: ', + cause: hostile, + }) }) it('observes an abort triggered synchronously inside the handler', async () => { diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 2ace80165c..2fe7398931 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -1229,6 +1229,7 @@ export function createTuiChat( commandControllers.add(controller) void ctx.commands.execute(agent, 'tui', text, controller.signal).then( (result) => { + if (disposed) return if (result === undefined) { appendNotice(`Unknown command: ${text}`, 'warning') } else if (result.text !== undefined && result.text !== '') { @@ -1342,7 +1343,12 @@ export function createTuiChat( } catch (error: unknown) { disposed = true detachListeners() - void commandFiber.dispose() + void commandFiber.dispose().catch( + /* v8 ignore next 2 -- command registration cleanup is non-throwing; this guards a future disposer regression */ + (cleanupError: unknown) => { + ctx.logger.warn(`ui-tui: command cleanup after startup failure failed: ${renderThrown(cleanupError)}`) + }, + ) clearStatus() disposeUserInteraction() ui.stop() diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index bf63aeaeb9..be67e117cd 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -510,6 +510,35 @@ describe('pi-tui chat lifecycle and transcript', () => { await result.ctx.fiber.dispose() }) + it('suppresses a successful plugin result that settles as TUI disposal starts', async () => { + const result = await setup() + let started!: () => void + const ready = new Promise((resolve) => { started = resolve }) + let resolveCommand!: (result: { kind: 'success'; text: string }) => void + result.ctx.commands.register({ + name: 'late-success', + description: 'Resolve while the TUI closes', + surfaces: ['tui'], + handler: () => new Promise((resolve) => { + resolveCommand = resolve + started() + }), + }) + + result.terminal.send('/late-success') + result.terminal.send('\r') + await ready + resolveCommand({ kind: 'success', text: 'must not render after disposal' }) + // Let the command boundary accept the result before disposal, but leave the + // TUI continuation queued so the success-side disposal guard owns the race. + await Promise.resolve() + await result.controller.dispose() + await tick() + + expect(result.terminal.output).not.toContain('must not render after disposal') + await result.ctx.fiber.dispose() + }) + it('cancels before /exit while running and handles agent errors/disposal', async () => { const result = await setup({ status: 'running' }) result.terminal.send('/exit') diff --git a/website/zh-CN/api/harness/commands.md b/website/zh-CN/api/harness/commands.md index 6f8ed33dfa..e59a2203da 100644 --- a/website/zh-CN/api/harness/commands.md +++ b/website/zh-CN/api/harness/commands.md @@ -6,7 +6,7 @@ Human-command registry. Plain-context definitions are global; definitions registered through a command-injected child of an agent context shadow globals for that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L216) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L235) ### ctx.commands.register(definition) @@ -25,7 +25,7 @@ Register a global or calling-agent-scoped command. **Returns** the exact effect disposer that unregisters this definition. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L229) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L248) ### ctx.commands.list(agent, surface) @@ -46,7 +46,7 @@ List the effective immutable command descriptors for one agent and surface. **Returns** name-sorted descriptors after scoped shadowing and surface filtering. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L257) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L276) ### ctx.commands.find(agent, surface, name) @@ -69,7 +69,7 @@ Resolve one effective command definition. **Returns** the scoped shadow or global definition when visible on the surface. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L272) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L291) ### ctx.commands.execute(agent, surface, line, signal) @@ -94,4 +94,4 @@ Parse and execute a known command without sending it to the model. **Returns** a detached result, or `undefined` when syntax/name/surface does not resolve. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L285) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L304) diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index 7ad96d6a81..35bf5480c7 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -482,14 +482,15 @@ Ask composed answerers for one decision. Return an outcome to claim the request /** * A command was registered or unregistered. This is an unfiltered registry * notification because a global or scoped change may affect any UI view. + * Observer failures are contained and cannot veto the registry mutation. * @mode emit */ 'commands/change'(): void ``` -A command was registered or unregistered. This is an unfiltered registry notification because a global or scoped change may affect any UI view. +A command was registered or unregistered. This is an unfiltered registry notification because a global or scoped change may affect any UI view. Observer failures are contained and cannot veto the registry mutation. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L93) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L94) ## fs/* From 4dd6266c53116f59e7604f828919c3103991c7c0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:54:34 +0800 Subject: [PATCH 12/25] docs(commands): refresh command event catalog --- packages/cordis/tool-cordis/src/api-catalog.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 0ec3d2926b..f5892671cb 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -812,7 +812,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'commands/change', mode: 'emit', signature: '\'commands/change\'(): void', - jsDoc: '/**\n * A command was registered or unregistered. This is an unfiltered registry\n * notification because a global or scoped change may affect any UI view.\n * @mode emit\n */', + jsDoc: '/**\n * A command was registered or unregistered. This is an unfiltered registry\n * notification because a global or scoped change may affect any UI view.\n * Observer failures are contained and cannot veto the registry mutation.\n * @mode emit\n */', summary: 'A command was registered or unregistered.', }, { From df1643d5e53142a874191602758b9ecbed059624 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:25:16 +0800 Subject: [PATCH 13/25] fix(goal): remove unreachable terminal branch --- packages/goal/tool-goal/src/index.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 5c395898b7..cd105b3705 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -111,10 +111,9 @@ function present(title: string, kind: 'read' | 'other', rawInput?: unknown): Gen function observeMutation( terminalTurns: WeakMap, execution: GoalToolExecution, - goal: GoalView, autonomousTerminal: boolean, ): void { - if (!autonomousTerminal || (goal.phase === 'active' && goal.activation === 'armed')) { + if (!autonomousTerminal) { terminalTurns.delete(execution.agent) return } @@ -173,7 +172,7 @@ export function apply(ctx: Context, config: Config): void { objective: args.objective, ...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds }, }) - observeMutation(terminalTurns, execution, goal, false) + observeMutation(terminalTurns, execution, false) return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) }, presentCall: args => present('Create goal', 'other', args.objective), @@ -207,7 +206,7 @@ export function apply(ctx: Context, config: Config): void { if (args.action === 'edit') { requireDirectHuman(ctx, execution) const goal = ctx.goals.edit(execution.agent, ref, replacements) - observeMutation(terminalTurns, execution, goal, false) + observeMutation(terminalTurns, execution, false) return Promise.resolve([{ type: 'text', text: renderGoal(goal), @@ -224,7 +223,7 @@ export function apply(ctx: Context, config: Config): void { const goal = args.action === 'pause' ? ctx.goals.pause(execution.agent, ref) : ctx.goals.resume(execution.agent, ref) - observeMutation(terminalTurns, execution, goal, false) + observeMutation(terminalTurns, execution, false) return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) } const authority = completionAuthority(ctx, execution) @@ -245,7 +244,7 @@ export function apply(ctx: Context, config: Config): void { const goal = args.action === 'complete' ? ctx.goals.complete(execution.agent, ref) : ctx.goals.block(execution.agent, ref) - observeMutation(terminalTurns, execution, goal, authority.kind === 'goal-round') + observeMutation(terminalTurns, execution, authority.kind === 'goal-round') return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) }, presentCall: args => present( From 15640ca697b5bfcee514faad467a1584a6b0cf01 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:53:10 +0800 Subject: [PATCH 14/25] fix(goal): require explained model blockers --- ...26-07-19-model-facing-goal-tools.i18n.yaml | 4 +- .../2026-07-19-model-facing-goal-tools.md | 8 +- .../2026-07-19-model-facing-goal-tools.zh.md | 8 +- docs/tool-catalog.md | 8 +- .../tests/fixtures/goal/tool-goal/cordis.yml | 26 ---- .../fixtures/goal/tool-goal/scripted-llm.ts | 88 ------------ .../headless-agent/goal.cordis.snapshot.yml | 13 ++ examples/headless-agent/goal.cordis.yml | 12 ++ .../headless-agent/tests/headless.snapshot.ts | 109 ++++++++++++--- .../tests/snapshots/goal-tools/input.json | 9 ++ .../snapshots/goal-tools/replay.override.json | 33 +++++ .../goal-tools/stream-json.expected.jsonl | 34 +++++ knip.json | 1 - packages/goal/tool-goal/README.md | 8 +- packages/goal/tool-goal/src/index.ts | 34 ++++- .../goal/tool-goal/tests/tool-goal.e2e.ts | 127 ------------------ .../goal/tool-goal/tests/tool-goal.spec.ts | 54 +++++++- 17 files changed, 289 insertions(+), 287 deletions(-) delete mode 100644 examples/echo-agent/tests/fixtures/goal/tool-goal/cordis.yml delete mode 100644 examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts create mode 100644 examples/headless-agent/goal.cordis.snapshot.yml create mode 100644 examples/headless-agent/goal.cordis.yml create mode 100644 examples/headless-agent/tests/snapshots/goal-tools/input.json create mode 100644 examples/headless-agent/tests/snapshots/goal-tools/replay.override.json create mode 100644 examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl delete mode 100644 packages/goal/tool-goal/tests/tool-goal.e2e.ts diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml index f857c5360e..fd97351a0e 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml @@ -2,5 +2,5 @@ # 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: # pnpm run verify-translation-pairing --write -2026-07-19-model-facing-goal-tools.md: e37eaabecfd1984e1198c26a460e78a92375dac1 -2026-07-19-model-facing-goal-tools.zh.md: c0d289271d2a8053193299c16a2bc9477f2f1038 +2026-07-19-model-facing-goal-tools.md: 2ef77b53cd8b95c9cdffd12e20c723fb1cff3a5d +2026-07-19-model-facing-goal-tools.zh.md: 08619600355c71ccd30d608e3c4e5a7fba753d4e diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md index e37eaabecf..2ef77b53cd 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md @@ -16,9 +16,9 @@ The surface also needs to preserve the separation between durable state and live ### Tools and model contract -`get_goal()` returns the current goal or `null`. A non-null result contains the compare-and-set id and revision, objective, durable phase, admitted and maximum goal rounds, plus the process-local activation observation. `create_goal(objective, max_goal_rounds?)` creates one long-running same-session objective. `update_goal(goal_id, revision, action, objective?, max_goal_rounds?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`; replacement fields are valid only for `edit`. +`get_goal()` returns the current goal or `null`. A non-null result contains the compare-and-set id and revision, objective, durable phase, admitted and maximum goal rounds, any blocker reason, plus the process-local activation observation. `create_goal(objective, max_goal_rounds?)` creates one long-running same-session objective. `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`; replacement fields are valid only for `edit`, while a non-empty `blocked_reason` is required only for `blocked` and persists under the stable `model-reported` code. -The prompt tells the model that it may infer goal intent from a direct human request in any wording or language, but should not convert routine single-turn work into a goal. It must read the current goal before updating and copy the exact id and revision. On a restored or forked active-but-disarmed goal, a semantic human request to continue is grounds for `resume`. Completion is reserved for an achieved objective, and difficulty or uncertainty alone is not a blocker. +The prompt tells the model that it may infer goal intent from a direct human request in any wording or language, but should not convert routine single-turn work into a goal. It must read the current goal before updating and copy the exact id and revision. On a restored or forked active-but-disarmed goal, a semantic human request to continue is grounds for `resume`. Completion is reserved for an achieved objective, and difficulty or uncertainty alone is not a blocker; a block report must name the concrete condition. All three tools use exclusive execution so a model-ordered batch observes prior mutations and their new revisions. Results are compact JSON. ACP presentation is a pure function of arguments and uses generic read or mutation cards; activation is reported only as live observation and is never written into replay state. @@ -34,11 +34,11 @@ Complete and blocked accept either direct-human authority or the exact current g ### Blocking threshold -`blockedAfterConsecutiveRounds` is a validated positive safe-integer configuration with default `3`. When an autonomous goal round calls `blocked`, the plugin mechanically requires at least that many admitted rounds; the configured value also appears in model guidance. The runtime cannot determine whether those rounds encountered the same blocking condition, so semantic equivalence remains a model judgment. This count is deliberately separate from the goal's generous continuation cap. +`blockedAfterConsecutiveRounds` is a validated positive safe-integer configuration with default `3`. When an autonomous goal round calls `blocked`, the plugin mechanically requires at least that many admitted rounds and a non-empty explanation; the configured value also appears in model guidance. The runtime cannot determine whether those rounds encountered the same blocking condition, so semantic equivalence remains a model judgment. This count is deliberately separate from the goal's generous continuation cap. ## Testing -Unit coverage pins registration and disposal, exclusive scheduling, generated prompt policy, generic presentation, direct-human creation in a non-English turn, exact/stale/non-running agent and driver checks, live-child rejection, resumed-fork root authority, steering, mismatched initiators, read/create/edit/pause/resume behavior, rearming after a session-start edge, authority-before-conditional-argument failures, exact goal-round completion, autonomous-only terminal stopping, the configured blocking threshold, and immediate human blocking. A keyless Loader/stdio process test mounts the real goal, tool, loop, and persistence plugins through `cordis.yml`, drives scripted model tool calls through a human pause and assistant acknowledgment, and reads the JSONL externally to verify the model-visible create/pause snapshots, structured tool results, and configured prompt text. +Unit coverage pins registration and disposal, exclusive scheduling, generated prompt policy, generic presentation, direct-human creation in a non-English turn, exact/stale/non-running agent and driver checks, live-child rejection, resumed-fork root authority, steering, mismatched initiators, read/create/edit/pause/resume behavior, conditional blocker explanations, rearming after a session-start edge, authority-before-conditional-argument failures, exact goal-round completion, autonomous-only terminal stopping, the configured blocking threshold, and immediate human blocking. A keyless replay snapshot mounts the goal domain and tools into the real headless one-shot application, drives `create_goal` and `get_goal` through the shipped loop and persistence stack, pins its stream-json transcript, and inspects the externally persisted goal change. The echo-agent fixture is intentionally not used as an application-UX surrogate. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md index c0d289271d..0861960035 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md @@ -16,9 +16,9 @@ Status: implemented ### 工具与模型契约 -`get_goal()` 返回当前目标或 `null`。非空结果包含用于比较并交换的 id 与修订号、目标描述、持久阶段、已接纳和最大目标回合数,以及进程本地激活态观察。`create_goal(objective, max_goal_rounds?)` 创建一个长时间运行的同会话目标。`update_goal(goal_id, revision, action, objective?, max_goal_rounds?)` 支持 `edit`、`pause`、`resume`、`complete` 和 `blocked`;替换字段仅对 `edit` 有效。 +`get_goal()` 返回当前目标或 `null`。非空结果包含用于比较并交换的 id 与修订号、目标描述、持久阶段、已接纳和最大目标回合数、可能存在的阻塞原因,以及进程本地激活态观察。`create_goal(objective, max_goal_rounds?)` 创建一个长时间运行的同会话目标。`update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` 支持 `edit`、`pause`、`resume`、`complete` 和 `blocked`;替换字段仅对 `edit` 有效,非空的 `blocked_reason` 仅在 `blocked` 时必填,并以稳定代码 `model-reported` 持久化。 -提示词告诉模型:它可以从任何措辞或语言的直接人类请求中推断目标意图,但不应把常规单轮工作转换为目标。更新前必须读取当前目标,并复制准确的 id 和修订号。对于恢复或派生后处于活跃但未激活状态的目标,人类在语义上要求继续即可成为执行 `resume` 的依据。只有目标已经实现时才能标记完成,困难或不确定性本身不构成阻塞。 +提示词告诉模型:它可以从任何措辞或语言的直接人类请求中推断目标意图,但不应把常规单轮工作转换为目标。更新前必须读取当前目标,并复制准确的 id 和修订号。对于恢复或派生后处于活跃但未激活状态的目标,人类在语义上要求继续即可成为执行 `resume` 的依据。只有目标已经实现时才能标记完成,困难或不确定性本身不构成阻塞;阻塞报告必须说明具体条件。 三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。ACP 展示是参数的纯函数,使用通用读取或变更卡片;激活态仅作为实时观察返回,绝不会写入回放状态。 @@ -34,11 +34,11 @@ Status: implemented ### 阻塞阈值 -`blockedAfterConsecutiveRounds` 是经过校验的正安全整数配置,默认值为 `3`。自主目标回合调用 `blocked` 时,插件会机械地要求至少已经接纳该数量的回合;配置值也会出现在模型指导中。运行时无法判断这些回合是否遇到了语义上相同的阻塞条件,因此语义等价性仍由模型判断。该计数特意与目标的宽裕继续执行上限分离。 +`blockedAfterConsecutiveRounds` 是经过校验的正安全整数配置,默认值为 `3`。自主目标回合调用 `blocked` 时,插件会机械地要求至少已经接纳该数量的回合并提供非空说明;配置值也会出现在模型指导中。运行时无法判断这些回合是否遇到了语义上相同的阻塞条件,因此语义等价性仍由模型判断。该计数特意与目标的宽裕继续执行上限分离。 ## 测试 -单元测试固定注册与释放、独占调度、生成的提示词策略、通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后派生根的权限、steering、发起者不匹配、读取/创建/编辑/暂停/恢复行为、会话启动边沿后的重新激活、权限先于条件参数失败、准确目标回合的完成、仅自主回合触发终止、可配置阻塞阈值,以及人类立即阻塞。无密钥 Loader/stdio 进程测试通过 `cordis.yml` 挂载真实的目标、工具、循环和持久化插件,驱动脚本化模型工具调用经过人类暂停与智能体确认,并从外部读取 JSONL,以验证模型可见的创建/暂停快照、结构化工具结果和配置后的提示词文本。 +单元测试固定注册与释放、独占调度、生成的提示词策略、通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后派生根的权限、steering、发起者不匹配、读取/创建/编辑/暂停/恢复行为、条件式阻塞说明、会话启动边沿后的重新激活、权限先于条件参数失败、准确目标回合的完成、仅自主回合触发终止、可配置阻塞阈值,以及人类立即阻塞。无密钥回放快照把目标领域和工具挂载到真实的 headless 单次运行应用中,通过随附循环与持久化栈驱动 `create_goal` 和 `get_goal`,固定 stream-json 转录,并检查外部持久化的目标变更。这里有意不把 echo-agent 测试夹具当作应用 UX 的替代品。 ## 考虑过的替代方案 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index febcf6a4bb..0244e39629 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -423,7 +423,7 @@ Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/ ### `get_goal` -Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal. +Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. ```json { @@ -436,7 +436,7 @@ Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/ ### `update_goal` -Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds. +Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. ```json { @@ -468,6 +468,10 @@ Update the exact current goal revision. edit, pause, and resume require a direct "max_goal_rounds": { "type": "number", "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ diff --git a/examples/echo-agent/tests/fixtures/goal/tool-goal/cordis.yml b/examples/echo-agent/tests/fixtures/goal/tool-goal/cordis.yml deleted file mode 100644 index fa63160399..0000000000 --- a/examples/echo-agent/tests/fixtures/goal/tool-goal/cordis.yml +++ /dev/null @@ -1,26 +0,0 @@ -# Test-only composition: drive all three goal tools through a real root agent. -- id: scripted-llm - name: './scripted-llm.ts' - -- id: bash - name: '@deepseek-ai/dsh-bash-local' - -- id: goal - name: '@deepseek-ai/dsh-goal' - config: - defaultMaxGoalRounds: 11 - -- id: tool-goal - name: '@deepseek-ai/dsh-tool-goal' - config: - blockedAfterConsecutiveRounds: 3 - -- id: stdio-agent - name: '@deepseek-ai/dsh-stdio-demo' - config: - provider: goal-script - model: goal-script - persona: 'Execute the deterministic goal-tool composition test.' - welcome: 'goal-tools e2e ready.' - persistenceRoot: './.sessions' - workspaceContext: false diff --git a/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts b/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts deleted file mode 100644 index 310737050d..0000000000 --- a/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** Deterministic adapter that creates, reads, pauses, then acknowledges one goal. */ - -import type { Context } from 'cordis' -import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, Message, StreamChunk } from '@deepseek-ai/dsh-llm' - -interface GoalState { - readonly id: string - readonly revision: number -} - -/** Text from the latest ordinary user message, excluding raw goal-state context. */ -function latestPrompt(messages: readonly Message[]): { index: number; text: string } { - for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = messages[index] - if (message?.role !== 'user') continue - const text = message.content - .filter(block => block.type === 'text' && !block.text.startsWith('')) - .map(block => block.type === 'text' ? block.text : '') - .join('\n') - if (text.length > 0) return { index, text } - } - return { index: -1, text: '' } -} - -/** Parse the latest domain snapshot rendered into history. */ -function latestGoal(messages: readonly Message[]): GoalState | undefined { - for (const message of [...messages].reverse()) { - for (const block of [...message.content].reverse()) { - if (block.type !== 'text' || !block.text.startsWith('')) continue - const json = block.text.slice(''.length, -''.length) - const value = JSON.parse(json) as { goal?: GoalState } - if (value.goal !== undefined) return value.goal - } - } - return undefined -} - -/** Names of tool calls recorded after the latest ordinary prompt. */ -function callsAfter(messages: readonly Message[], index: number): string[] { - return messages.slice(index + 1).flatMap(message => message.content) - .filter(block => block.type === 'tool-call') - .map(block => block.type === 'tool-call' ? block.name : '') -} - -/** Emit one tool-call response. */ -async function* toolCall(name: string, args: object): AsyncIterable { - const id = CallId(`call-${name}`) - const raw = JSON.stringify(args) - yield { type: 'block-start', index: 0, blockType: 'tool-call' } - yield { type: 'tool-call-delta', index: 0, id, name, argumentsDelta: raw } - yield { type: 'block-end', index: 0, block: { type: 'tool-call', id, name, arguments: raw } } - yield { type: 'finish', reason: { kind: 'tool-calls' } } -} - -/** Emit one terminal text response. */ -async function* textReply(text: string): AsyncIterable { - yield { type: 'block-start', index: 0, blockType: 'text' } - yield { type: 'text-delta', index: 0, text } - yield { type: 'block-end', index: 0, block: { type: 'text', text } } - yield { type: 'finish', reason: { kind: 'stop' } } -} - -class GoalScriptAdapter extends LlmAdapter { - override stream(options: GenerateOptions): AsyncIterable { - const prompt = latestPrompt(options.messages) - const calls = callsAfter(options.messages, prompt.index) - if (prompt.text === 'start' && !calls.includes('create_goal')) { - return toolCall('create_goal', { objective: 'Finish the composed goal-tool proof', max_goal_rounds: 7 }) - } - if (prompt.text === 'start' && !calls.includes('get_goal')) return toolCall('get_goal', {}) - if (prompt.text === 'start') return textReply('GOAL CREATED') - if (prompt.text === 'pause' && !calls.includes('update_goal')) { - const goal = latestGoal(options.messages) - if (goal === undefined) throw new Error('scripted goal state missing') - return toolCall('update_goal', { goal_id: goal.id, revision: goal.revision, action: 'pause' }) - } - if (prompt.text === 'pause') return textReply('GOAL PAUSED') - return textReply('UNEXPECTED PROMPT') - } -} - -export const name = 'goal-tool-scripted-llm' -export const inject = ['llm'] - -export function apply(ctx: Context): void { - ctx.llm.registerAdapter(['goal-script'], new GoalScriptAdapter()) -} diff --git a/examples/headless-agent/goal.cordis.snapshot.yml b/examples/headless-agent/goal.cordis.snapshot.yml new file mode 100644 index 0000000000..185d3dd11e --- /dev/null +++ b/examples/headless-agent/goal.cordis.snapshot.yml @@ -0,0 +1,13 @@ +# Replay counterpart to goal.cordis.yml; only the live model is replaced. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./goal.cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + diff --git a/examples/headless-agent/goal.cordis.yml b/examples/headless-agent/goal.cordis.yml new file mode 100644 index 0000000000..01f1726100 --- /dev/null +++ b/examples/headless-agent/goal.cordis.yml @@ -0,0 +1,12 @@ +# Add the persisted goal domain and its model-facing tools to the real one-shot app. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - insert: + - id: goal + name: '@deepseek-ai/dsh-goal' + - id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index dc448b9b23..d799c6250d 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -11,10 +11,12 @@ import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-l import { describe, expect, it } from 'vitest' const snapshotsDir = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') -const scenarioDir = join(snapshotsDir, 'advanced-toolchain') -const sessionFixture = join(scenarioDir, 'session.jsonl') -const streamExpected = join(scenarioDir, 'stream-json.expected.jsonl') -const configPath = fileURLToPath(new URL('../advanced.cordis.snapshot.yml', import.meta.url)) +const advancedScenarioDir = join(snapshotsDir, 'advanced-toolchain') +const advancedSessionFixture = join(advancedScenarioDir, 'session.jsonl') +const advancedStreamExpected = join(advancedScenarioDir, 'stream-json.expected.jsonl') +const advancedConfigPath = fileURLToPath(new URL('../advanced.cordis.snapshot.yml', import.meta.url)) +const goalScenarioDir = join(snapshotsDir, 'goal-tools') +const goalConfigPath = fileURLToPath(new URL('../goal.cordis.snapshot.yml', import.meta.url)) const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) const refreshing = process.env.DSH_SNAPSHOT === 'refresh' @@ -70,12 +72,36 @@ function normalizeHeadlessStream(rawStdout: string, cwd: string): string { return normalizeStdout(`${normalizedRecords.map(record => JSON.stringify(record)).join('\n')}\n`, context) } -async function advancedPrompt(): Promise { - const input = JSON.parse(await readFile(join(scenarioDir, 'input.json'), 'utf8')) as { +/** Zero durable goal timestamps inside both metadata records and rendered XML JSON. */ +function normalizeGoalTimestamps(value: unknown): unknown { + if (typeof value === 'string') { + return value.replace(/("(?:createdAt|updatedAt|clearedAt)":)\d+/g, '$10') + } + if (Array.isArray(value)) return value.map(normalizeGoalTimestamps) + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [ + key, + ['createdAt', 'updatedAt', 'clearedAt'].includes(key) && typeof item === 'number' + ? 0 + : normalizeGoalTimestamps(item), + ])) + } + return value +} + +/** Normalize the stream's durable goal timestamps after the shared scrubbers. */ +function normalizeGoalStream(rawStdout: string, cwd: string): string { + return parseJsonl(normalizeHeadlessStream(rawStdout, cwd)) + .map(record => JSON.stringify(normalizeGoalTimestamps(record))) + .join('\n') + '\n' +} + +async function scenarioPrompt(dir: string, label: string): Promise { + const input = JSON.parse(await readFile(join(dir, 'input.json'), 'utf8')) as { steps?: { op?: unknown; text?: unknown }[] } const prompt = input.steps?.find(step => step.op === 'prompt')?.text - if (typeof prompt !== 'string') throw new Error('advanced-toolchain input has no prompt step') + if (typeof prompt !== 'string') throw new Error(`${label} input has no prompt step`) return prompt } @@ -90,24 +116,27 @@ async function persistedLogs(cwd: string): Promise { describe('headless stream-json snapshots', () => { it('replays the advanced toolchain through the one-shot app', async () => { - const prompt = await advancedPrompt() + const prompt = await scenarioPrompt(advancedScenarioDir, 'advanced-toolchain') const expectedSessions = await Promise.all([ - sessionFixture, - join(scenarioDir, 'session.1.jsonl'), - join(scenarioDir, 'session.2.jsonl'), + advancedSessionFixture, + join(advancedScenarioDir, 'session.1.jsonl'), + join(advancedScenarioDir, 'session.2.jsonl'), ].map(file => readFile(file, 'utf8'))) let runCwd = '' const result = await runLoaderSmoke({ label: 'advanced headless stream-json snapshot', tempDirPrefix: 'headless-snapshot-advanced-', binScript, - configPath, - binArgs: ['--config', configPath, '--output-format', 'stream-json', prompt], + configPath: advancedConfigPath, + binArgs: ['--config', advancedConfigPath, '--output-format', 'stream-json', prompt], tsconfigPath, env: { DSH_SNAPSHOT: 'replay', - DSH_SNAPSHOT_FILE: sessionFixture, - DSH_SNAPSHOT_CHILD_FILES: [join(scenarioDir, 'session.1.jsonl'), join(scenarioDir, 'session.2.jsonl')].join(delimiter), + DSH_SNAPSHOT_FILE: advancedSessionFixture, + DSH_SNAPSHOT_CHILD_FILES: [ + join(advancedScenarioDir, 'session.1.jsonl'), + join(advancedScenarioDir, 'session.2.jsonl'), + ].join(delimiter), NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), }, prepare: (cwd) => { runCwd = cwd }, @@ -134,6 +163,56 @@ describe('headless stream-json snapshots', () => { expect(result.stderr).toBe('') const normalized = normalizeHeadlessStream(result.stdout, runCwd) + if (refreshing) await writeFile(advancedStreamExpected, normalized) + expect(normalized).toBe(await readFile(advancedStreamExpected, 'utf8')) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('replays persisted goal tools through the one-shot app', async () => { + const prompt = await scenarioPrompt(goalScenarioDir, 'goal-tools') + const streamExpected = join(goalScenarioDir, 'stream-json.expected.jsonl') + let runCwd = '' + const result = await runLoaderSmoke({ + label: 'goal tools headless stream-json snapshot', + tempDirPrefix: 'headless-snapshot-goal-tools-', + binScript, + configPath: goalConfigPath, + binArgs: ['--config', goalConfigPath, '--output-format', 'stream-json', prompt], + tsconfigPath, + env: { + DSH_SNAPSHOT: 'replay', + DSH_SNAPSHOT_FILE: join(goalScenarioDir, 'session.jsonl'), + DSH_SNAPSHOT_OVERRIDE: join(goalScenarioDir, 'replay.override.json'), + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + prepare: (cwd) => { runCwd = cwd }, + inspect: async (cwd) => { + const logs = await persistedLogs(cwd) + expect(logs).toHaveLength(1) + const records = parseJsonl(logs[0]?.content ?? '') + const calls = records.filter(record => record.type === 'tool/call') + .map(record => (record.data as JsonObject | undefined)?.name) + expect(calls).toEqual(['create_goal', 'get_goal']) + const goalChanges = records.filter((record) => { + if (record.type !== 'context/message') return false + const data = record.data as JsonObject | undefined + const meta = data?.meta as JsonObject | undefined + return meta?.kind === 'goal/change' + }) + expect(goalChanges).toHaveLength(1) + const data = goalChanges[0]?.data as JsonObject | undefined + const meta = data?.meta as JsonObject | undefined + const goal = meta?.goal as JsonObject | undefined + expect(meta?.operation).toBe('create') + expect(goal).toMatchObject({ + objective: 'Finish the headless goal-tool snapshot proof', + phase: 'active', + maxGoalRounds: 7, + }) + }, + }) + + expect(result.stderr).toBe('') + const normalized = normalizeGoalStream(result.stdout, runCwd) if (refreshing) await writeFile(streamExpected, normalized) expect(normalized).toBe(await readFile(streamExpected, 'utf8')) }, LOADER_SMOKE_TEST_TIMEOUT_MS) diff --git a/examples/headless-agent/tests/snapshots/goal-tools/input.json b/examples/headless-agent/tests/snapshots/goal-tools/input.json new file mode 100644 index 0000000000..cb0b3bbd82 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/goal-tools/input.json @@ -0,0 +1,9 @@ +{ + "steps": [ + { + "op": "prompt", + "text": "Create a durable goal to finish the snapshot proof, then inspect it." + } + ] +} + diff --git a/examples/headless-agent/tests/snapshots/goal-tools/replay.override.json b/examples/headless-agent/tests/snapshots/goal-tools/replay.override.json new file mode 100644 index 0000000000..7e4fd90c6c --- /dev/null +++ b/examples/headless-agent/tests/snapshots/goal-tools/replay.override.json @@ -0,0 +1,33 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_goal_create", "name": "create_goal", "argumentsDelta": "{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_goal_create", "name": "create_goal", "arguments": "{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}" } }, + { "type": "usage", "usage": { "inputTokens": 20, "outputTokens": 8 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_goal_get", "name": "get_goal", "argumentsDelta": "{}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_goal_get", "name": "get_goal", "arguments": "{}" } }, + { "type": "usage", "usage": { "inputTokens": 30, "outputTokens": 4 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "GOAL READY" }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "GOAL READY" } }, + { "type": "usage", "usage": { "inputTokens": 35, "outputTokens": 2 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] + diff --git a/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl new file mode 100644 index 0000000000..b6e9ad7e70 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl @@ -0,0 +1,34 @@ +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Create a durable goal to finish the snapshot proof, then inspect it."}],"source":{"kind":"user"}},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_create","name":"create_goal","argumentsDelta":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"context/message","seq":12,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":7},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"envelope":"raw","meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the headless goal-tool snapshot proof","phase":"active","maxGoalRounds":7},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_get","name":"get_goal","argumentsDelta":"{}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":30,"outputTokens":4}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_get","name":"get_goal","arguments":"{}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_get","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":24,"time":0,"data":{"turn":1,"step":3}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"GOAL READY"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL READY"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":35,"outputTokens":2}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":30,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"text","text":"GOAL READY"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":35,"outputTokens":2}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":31,"time":0,"data":{"turn":1,"step":3}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":32,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} +{"type":"result","success":true,"sessionId":"{{sessionId}}","turn":1,"result":"GOAL READY","reason":{"kind":"completed"},"usage":{"inputTokens":85,"outputTokens":14}} diff --git a/knip.json b/knip.json index eb4eff3a9f..9b7b511c44 100644 --- a/knip.json +++ b/knip.json @@ -11,7 +11,6 @@ "entry": [ "echo-agent/src/*.ts", "echo-agent/tests/fixtures/goal/goal/seed-goal.ts", - "echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts", "headless-agent/tests/fixtures/cli-mock-llm.ts", "tui-agent/tests/fixtures/tui-scripted-llm.ts", "*/tests/**/*.e2e.ts", diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index 1ac03ac04a..3b97d7aa82 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -4,9 +4,9 @@ The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal ## Tools -- `get_goal()` returns the current goal or `null`, including the compare-and-set id/revision, durable phase, admitted/capped goal rounds, and current process-local activation. +- `get_goal()` returns the current goal or `null`, including the compare-and-set id/revision, durable phase, admitted/capped goal rounds, any blocker reason, and current process-local activation. - `create_goal(objective, max_goal_rounds?)` creates one goal from a direct top-level human turn. The model may infer long-running goal intent without an exact command phrase; non-human turns and subagents are rejected at execution. -- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. Replacements belong only to `edit`. +- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. Replacements belong only to `edit`; `blocked_reason` is required only for `blocked` and is persisted with the stable code `model-reported`. All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations. @@ -18,7 +18,7 @@ Execution requires the exact live `exec.agent`, its inherited `AgentRegistry` in `{ 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. -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. 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. ## Config @@ -42,7 +42,7 @@ A fixed goal policy says when semantic human intent warrants creation, requires ##### Goal policy ```markdown -Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked. +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. ``` #### Token effect diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index cd105b3705..075264f93e 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -51,7 +51,8 @@ const CREATE_DESCRIPTION = const GET_DESCRIPTION = 'Read the current same-session goal, including its exact id/revision, objective, phase, completed ' - + 'continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal.' + + 'continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. ' + + 'Call this before updating a goal.' /** Render policy guidance with its deployment-selected blocked threshold. */ function guidance(blockedAfter: number): string { @@ -62,7 +63,8 @@ function guidance(blockedAfter: number): string { + 'a human asks to continue or resume in any wording or language, use update_goal action ' + 'resume to rearm it. Mark complete only when the objective is actually achieved. Mark ' + `blocked only after the same blocking condition persists for at least ${blockedAfter} ` - + 'consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked.' + + 'consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, ' + + 'or useful remaining work is not blocked.' } /** Validate config even when apply is called directly outside Loader normalization. */ @@ -97,6 +99,7 @@ function renderGoal(goal: GoalView | undefined): string { phase: goal.phase, roundsStarted: goal.roundsStarted, maxGoalRounds: goal.maxGoalRounds, + ...goal.blockedReason === undefined ? {} : { blockedReason: goal.blockedReason }, }, activation: goal.activation, }) @@ -183,7 +186,7 @@ export function apply(ctx: Context, config: Config): void { description: 'Update the exact current goal revision. edit, pause, and resume require a direct ' + 'top-level human request. During an automatic continuation of the current goal, complete ' + 'and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains ' - + 'responsible for judging that the same condition persisted across those rounds.', + + 'responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.', parameters: { goal_id: { type: 'string', required: true, description: 'Exact id returned by get_goal.' }, revision: { type: 'number', required: true, description: 'Exact positive revision returned by get_goal.' }, @@ -195,6 +198,10 @@ export function apply(ctx: Context, config: Config): void { }, objective: { type: 'string', description: 'Replacement objective; valid only with action edit.' }, max_goal_rounds: { type: 'number', description: 'Replacement cap; valid only with action edit.' }, + blocked_reason: { + type: 'string', + description: 'Concrete blocking condition; required only with action blocked.', + }, }, execute(args, exec) { const execution = goalToolExecution(ctx, exec) @@ -205,6 +212,9 @@ export function apply(ctx: Context, config: Config): void { } if (args.action === 'edit') { requireDirectHuman(ctx, execution) + if (args.blocked_reason !== undefined) { + throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE') + } const goal = ctx.goals.edit(execution.agent, ref, replacements) observeMutation(terminalTurns, execution, false) return Promise.resolve([{ @@ -214,9 +224,9 @@ export function apply(ctx: Context, config: Config): void { } if (args.action === 'pause' || args.action === 'resume') { requireDirectHuman(ctx, execution) - if (args.objective !== undefined || args.max_goal_rounds !== undefined) { + if (args.objective !== undefined || args.max_goal_rounds !== undefined || args.blocked_reason !== undefined) { throw new HarnessError( - 'objective and max_goal_rounds are valid only with action edit', + 'objective and max_goal_rounds are valid only with action edit; blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE', ) } @@ -233,6 +243,13 @@ export function apply(ctx: Context, config: Config): void { 'GOAL_TOOL_INVALID_UPDATE', ) } + if (args.action === 'complete' && args.blocked_reason !== undefined) { + throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE') + } + if (args.action === 'blocked' + && (args.blocked_reason === undefined || args.blocked_reason.trim().length === 0)) { + throw new HarnessError('blocked_reason is required with action blocked', 'GOAL_TOOL_INVALID_UPDATE') + } if (args.action === 'blocked' && authority.kind === 'goal-round' && authority.goal.roundsStarted < resolved.blockedAfterConsecutiveRounds) { throw new HarnessError( @@ -243,14 +260,17 @@ export function apply(ctx: Context, config: Config): void { } const goal = args.action === 'complete' ? ctx.goals.complete(execution.agent, ref) - : ctx.goals.block(execution.agent, ref) + : ctx.goals.block(execution.agent, ref, { + code: 'model-reported', + message: args.blocked_reason as string, + }) observeMutation(terminalTurns, execution, authority.kind === 'goal-round') return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) }, presentCall: args => present( `${args.action === 'blocked' ? 'Mark' : args.action.charAt(0).toUpperCase() + args.action.slice(1)} goal`, 'other', - args.objective ?? args.goal_id, + args.blocked_reason ?? args.objective ?? args.goal_id, ), })) } diff --git a/packages/goal/tool-goal/tests/tool-goal.e2e.ts b/packages/goal/tool-goal/tests/tool-goal.e2e.ts deleted file mode 100644 index b19a4ee851..0000000000 --- a/packages/goal/tool-goal/tests/tool-goal.e2e.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' -import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { afterEach, describe, expect, it } from 'vitest' -import { decodeGoalChange } from '@deepseek-ai/dsh-goal' -import type { SessionEvent } from '@deepseek-ai/dsh-session' -import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' - -const binScript = fileURLToPath(new URL('../../../examples/stdio-demo/src/bin.ts', import.meta.url)) -const configPath = fileURLToPath(new URL( - '../../../../examples/echo-agent/tests/fixtures/goal/tool-goal/cordis.yml', - import.meta.url, -)) -const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) -const PROCESS_TIMEOUT_MS = 30_000 -const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000 -const PAUSED_RESULT = '"phase":"paused"' - -let child: ChildProcessWithoutNullStreams | undefined -let workdir: string | undefined - -afterEach(async () => { - if (child !== undefined && child.exitCode === null) child.kill('SIGKILL') - child = undefined - if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) - workdir = undefined -}) - -async function jsonlFiles(dir: string): Promise { - const entries = await readdir(dir, { withFileTypes: true }) - const paths = await Promise.all(entries.map(async (entry) => { - const path = join(dir, entry.name) - if (entry.isDirectory()) return jsonlFiles(path) - return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : [] - })) - return paths.flat() -} - -async function runComposition(): Promise<{ stdout: string; stderr: string }> { - workdir = await mkdtemp(join(tmpdir(), 'goal-tools-e2e-')) - const cwd = workdir - return new Promise((resolve, reject) => { - const launch = resolveExampleLaunch({ - srcBin: binScript, - configArgs: [configPath], - tsconfigPath: repoTsconfig, - exposeInternals: true, - env: { - DSH_HOME: join(cwd, '.dsh'), - DSH_AGENTS_HOME: join(cwd, '.agents'), - }, - }) - const proc = spawn(launch.command, launch.args, { - cwd, - env: { ...process.env, ...launch.env }, - stdio: ['pipe', 'pipe', 'pipe'], - }) - child = proc - let stdout = '' - let stderr = '' - let pauseSent = false - let inputClosed = false - proc.stdout.setEncoding('utf8') - proc.stdout.on('data', (chunk: string) => { - stdout += chunk - if (!pauseSent && stdout.includes('GOAL CREATED') && stdout.includes('\n> ')) { - pauseSent = true - proc.stdin.write('pause\n') - } - const pausedAt = stdout.indexOf(PAUSED_RESULT) - if (!inputClosed && pausedAt >= 0 && stdout.indexOf('\n> ', pausedAt) >= 0) { - inputClosed = true - proc.stdin.end() - } - }) - proc.stderr.setEncoding('utf8') - proc.stderr.on('data', (chunk: string) => { stderr += chunk }) - - const timer = setTimeout(() => { - proc.kill('SIGKILL') - reject(new Error( - `goal-tools e2e did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`, - )) - }, PROCESS_TIMEOUT_MS) - proc.on('exit', (code) => { - clearTimeout(timer) - if (code === 0) resolve({ stdout, stderr }) - else reject(new Error(`goal-tools e2e exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }) - proc.on('error', (error) => { clearTimeout(timer); reject(error) }) - proc.stdin.write('start\n') - }) -} - -describe('goal tools through a real Loader, app, and stdio process', () => { - it('creates, reads, and pauses one root goal with durable tool and state records', async () => { - const { stdout, stderr } = await runComposition() - expect(stderr).not.toContain('UNHANDLED') - expect(stdout).toContain('goal-tools e2e ready.') - expect(stdout).toContain('GOAL CREATED') - expect(stdout).toContain(PAUSED_RESULT) - expect(stdout).toContain('GOAL PAUSED') - - const logs = await jsonlFiles(join(workdir as string, '.sessions')) - expect(logs).toHaveLength(1) - const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n') - const events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent) - const calls = events.filter(event => event.type === 'tool/call') - expect(calls.map(event => event.data.name)).toEqual(['create_goal', 'get_goal', 'update_goal']) - const results = events.filter(event => event.type === 'tool/result') - expect(results).toHaveLength(3) - expect(results.every(event => !event.data.isError)).toBe(true) - - const changes = events - .filter(event => event.type === 'context/message' && event.data.source.kind === 'goal') - .map(event => event.type === 'context/message' ? decodeGoalChange(event.data.meta) : undefined) - expect(changes.map(change => change?.operation)).toEqual(['create', 'pause']) - expect(changes[1]).toMatchObject({ goal: { phase: 'paused', revision: 2, maxGoalRounds: 7 } }) - expect(JSON.stringify(changes)).not.toContain('activation') - - const headers = events.filter(event => event.type === 'request/header') - expect(JSON.stringify(headers)).toContain('infer goal intent') - expect(JSON.stringify(headers)).toContain('at least 3 consecutive goal rounds') - }, TEST_TIMEOUT_MS) -}) diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 044fe2369a..cd45c145c7 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -135,8 +135,8 @@ describe('goal tool registration and presentation', () => { card: 'generic', title: 'Create goal', kind: 'other', rawInput: 'ship', }) expect(ctx.tools.get('update_goal')?.presentCall?.({ - goal_id: 'goal-1', revision: 2, action: 'blocked', - })).toEqual({ card: 'generic', title: 'Mark goal', kind: 'other', rawInput: 'goal-1' }) + goal_id: 'goal-1', revision: 2, action: 'blocked', blocked_reason: 'Waiting for a human choice.', + })).toEqual({ card: 'generic', title: 'Mark goal', kind: 'other', rawInput: 'Waiting for a human choice.' }) expect(ctx.tools.get('update_goal')?.presentCall?.({ goal_id: 'goal-1', revision: 2, action: 'resume', })).toEqual({ card: 'generic', title: 'Resume goal', kind: 'other', rawInput: 'goal-1' }) @@ -391,6 +391,26 @@ describe('goal tool state transitions', () => { max_goal_rounds: 2, }, root.agent) expect(terminalUpdate.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + const blockedWithoutReason = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'blocked', + }, root.agent) + expect(blockedWithoutReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + const blockedWithEmptyReason = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'blocked', blocked_reason: ' ', + }, root.agent) + expect(blockedWithEmptyReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + const completeWithReason = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'complete', blocked_reason: 'Not a blocker.', + }, root.agent) + expect(completeWithReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + const editWithReason = await execute(ctx, 'update_goal', { + goal_id: created.id, + revision: created.revision, + action: 'edit', + objective: 'still valid', + blocked_reason: 'Not valid for edit.', + }, root.agent) + expect(editWithReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') const malformedRef = await execute(ctx, 'update_goal', { goal_id: '', revision: 0, action: 'edit', objective: 'x', }, root.agent) @@ -423,16 +443,26 @@ describe('goal tool state transitions', () => { for (let round = 1; round <= 2; round += 1) { turn = openTurn(root, { kind: 'goal', goalId: ref.id, revision: ref.revision, round }) const result = await execute(ctx, 'update_goal', { - goal_id: ref.id, revision: ref.revision, action: 'blocked', + goal_id: ref.id, + revision: ref.revision, + action: 'blocked', + blocked_reason: 'The required credential is still unavailable.', }, root.agent) expect(result.error?.code).toBe('GOAL_TOOL_BLOCK_THRESHOLD') closeTurn(root, turn) } openTurn(root, { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 3 }) const blocked = await execute(ctx, 'update_goal', { - goal_id: ref.id, revision: ref.revision, action: 'blocked', + goal_id: ref.id, + revision: ref.revision, + action: 'blocked', + blocked_reason: 'The required credential is still unavailable.', }, root.agent) - expect(resultGoal(blocked)).toMatchObject({ phase: 'blocked', roundsStarted: 3 }) + expect(resultGoal(blocked)).toMatchObject({ + phase: 'blocked', + blockedReason: { code: 'model-reported', message: 'The required credential is still unavailable.' }, + roundsStarted: 3, + }) }) it('lets direct human authority block before the model threshold', async () => { @@ -440,8 +470,18 @@ describe('goal tool state transitions', () => { openTurn(root, { kind: 'user' }) const created = ctx.goals.create(root.agent, { objective: 'human stop' }) const blocked = await execute(ctx, 'update_goal', { - goal_id: created.id, revision: created.revision, action: 'blocked', + goal_id: created.id, + revision: created.revision, + action: 'blocked', + blocked_reason: 'The user asked to stop until a prerequisite is available.', }, root.agent) - expect(resultGoal(blocked)).toMatchObject({ phase: 'blocked', roundsStarted: 0 }) + expect(resultGoal(blocked)).toMatchObject({ + phase: 'blocked', + blockedReason: { + code: 'model-reported', + message: 'The user asked to stop until a prerequisite is available.', + }, + roundsStarted: 0, + }) }) }) From 23330b7a7d00fa6f4f058423aea57d4552792808 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:35:01 +0800 Subject: [PATCH 15/25] chore(goal): remove retired e2e dependencies --- knip.json | 2 +- packages/goal/tool-goal/package.json | 1 - pnpm-lock.yaml | 3 --- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/knip.json b/knip.json index 9b7b511c44..8fa18dc524 100644 --- a/knip.json +++ b/knip.json @@ -72,7 +72,7 @@ "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/goal/tool-goal": { - "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "entry": ["tests/**/*.spec.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/code-runtime/code-runtime-worker": { diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index 28cd6839f8..b9b4912cf4 100644 --- a/packages/goal/tool-goal/package.json +++ b/packages/goal/tool-goal/package.json @@ -38,7 +38,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 957e3d05dd..9f7a641df7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -425,9 +425,6 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - '@deepseek-ai/dsh-loader-smoke': - specifier: workspace:^ - version: link:../../support/loader-smoke '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session From 4e7a2c732f77834e2dc5d03e3861e4a0c18bd132 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:41:22 +0800 Subject: [PATCH 16/25] fix(commands): remove adapter surface filtering --- ...7-19-plugin-command-registration.i18n.yaml | 4 +- .../2026-07-19-plugin-command-registration.md | 11 ++-- ...26-07-19-plugin-command-registration.zh.md | 11 ++-- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 23 +++---- docs/core-data-structures/commands.md | 19 ++---- docs/event-producer-consumer.md | 2 +- docs/glossary.md | 1 - .../cordis/tool-cordis/src/api-catalog.ts | 24 +++---- packages/ui/acp/src/index.ts | 4 +- packages/ui/acp/tests/commands.spec.ts | 5 +- packages/ui/commands/README.md | 4 +- packages/ui/commands/src/index.ts | 56 ++++------------- packages/ui/commands/tests/commands.spec.ts | 63 ++++++++----------- packages/ui/tui/src/index.ts | 13 +--- packages/ui/tui/tests/tui.spec.ts | 11 +--- scripts/type-equiv.manifest.json | 1 - website/zh-CN/api/harness/commands.md | 48 +++++++------- website/zh-CN/api/harness/events.md | 2 +- 19 files changed, 112 insertions(+), 192 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml index 48d880e666..7294638f1a 100644 --- a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml @@ -2,5 +2,5 @@ # 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: # pnpm run verify-translation-pairing --write -2026-07-19-plugin-command-registration.md: 821f22405fd6a4bc0b8bfd3c5edf773be844899d -2026-07-19-plugin-command-registration.zh.md: 7a2ed82eb1a8f55d4d701a68c4053e5d412b7d04 +2026-07-19-plugin-command-registration.md: c414f8183e100f712a552828992d108193fc33cf +2026-07-19-plugin-command-registration.zh.md: 3cd820c55ec30f3b2f6cf471376abfd8edd8ac5e diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md index 821f22405f..c414f8183e 100644 --- a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md +++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md @@ -16,9 +16,9 @@ A shared mechanism must remain a UI concern rather than a model tool or agent-lo ### Registry contract -A `CommandDefinition` contains a lowercase name without `/`, a non-empty description, an optional unstructured-input hint, an optional non-empty surface list, and an abortable handler. Omitted surfaces resolve to `tui` plus `acp`. Registration validates and detaches the metadata, freezes the effective definition, and returns the exact Cordis effect disposer. Duplicate names fail within one layer. +A `CommandDefinition` contains a lowercase name without `/`, a non-empty description, an optional unstructured-input hint, and an abortable handler. Registration validates and detaches the metadata, freezes the effective definition, and returns the exact Cordis effect disposer. Duplicate names fail within one layer. Every adapter consuming the registry sees every effective definition; a command plugin that cannot operate in a deployment omits its registration there instead of encoding adapter identities in the shared domain. -`list(agent, surface)` returns immutable name-sorted descriptors after surface filtering and scoped shadowing. `find(agent, surface, name)` resolves the effective definition. `execute(agent, surface, line, signal)` parses and runs a visible definition, returning a detached `success` or `error` result; invalid syntax, unknown names, and hidden definitions return `undefined` so the adapter owns its direct error text. +`list(agent)` returns immutable name-sorted descriptors after scoped shadowing. `find(agent, name)` resolves the effective definition. `execute(agent, line, signal)` parses and runs a known definition, returning a detached `success` or `error` result; invalid syntax and unknown names return `undefined` so the adapter owns its direct error text. `parseCommand(line)` requires `/` at byte zero, a lowercase ASCII name containing letters, digits, `_`, or `-`, then whitespace or end-of-input. It preserves the complete adapter-delivered suffix as `rawInput`, including separator whitespace. Command-specific plugins own every further grammar decision. @@ -30,13 +30,13 @@ Registration and removal emit the unfiltered, non-vetoing `commands/change` regi ### Direct dispatch and cancellation -Commands run in a human-only command plane. Their input does not become `user/message`, their output does not become a session event, and neither is sent to the model. A handler receives the exact target agent, surface, raw input, and request-owned `AbortSignal`. The registry stops awaiting an uncooperative handler when the signal aborts; the handler remains responsible for stopping external side effects already started. +Commands run in a human-only command plane. Their input does not become `user/message`, their output does not become a session event, and neither is sent to the model. A handler receives the exact target agent, raw input, and request-owned `AbortSignal`. The registry stops awaiting an uncooperative handler when the signal aborts; the handler remains responsible for stopping external side effects already started. Expected handler failures return `CommandResult.error`. Thrown or malformed results remain adapter-visible command failures, not model messages. This boundary deliberately separates UI output from durable domain mutation: a goal command may change `ctx.goals`, for example, but the goal service owns that persisted state. ### TUI mapping -The TUI registers `help`, `clear`, `cancel`, `reasoning`, `tools`, `redraw`, and `exit` as agent-scoped command definitions instead of switching on strings. Its autocomplete and help view read the live `tui` catalog, so plugin commands appear and disappear with their effects. Any submitted line beginning with `/` stays in the command plane; unknown input produces a terminal warning rather than falling through to `Agent.send()` or `Agent.steer()`. +The TUI registers `help`, `clear`, `cancel`, `reasoning`, `tools`, `redraw`, and `exit` as agent-scoped command definitions instead of switching on strings. Its autocomplete and help view read the live catalog, so plugin commands appear and disappear with their effects. Any submitted line beginning with `/` stays in the command plane; unknown input produces a terminal warning rather than falling through to `Agent.send()` or `Agent.steer()`. Each submitted command owns an `AbortController`. TUI disposal aborts outstanding dispatches, removes the local definitions, and waits for the command-producing fiber before completing teardown. @@ -50,7 +50,7 @@ One model prompt or direct command may be in flight per ACP session, independent ## Testing -The registry suite covers syntax boundaries, immutable normalization, runtime metadata validation, default and explicit surfaces, deterministic sorting, global and scoped shadowing, duplicate rejection, exact disposal, contained change-notification failures, direct invocation, expected and malformed results, synchronous and asynchronous failure, and every abort timing edge at per-file 100% statement, branch, function, and line coverage. +The registry suite covers syntax boundaries, immutable normalization, runtime metadata validation, deterministic sorting, global and scoped shadowing, duplicate rejection, exact disposal, contained change-notification failures, direct invocation, expected and malformed results, synchronous and asynchronous failure, and every abort timing edge at per-file 100% statement, branch, function, and line coverage. TUI tests exercise all migrated built-ins, live plugin discovery, help/autocomplete refresh, direct results, unknown-command rejection, raw-input delivery, definition removal, startup rollback, and disposal cancellation. ACP tests use the real SDK connection, agent factory, loop, and JSONL persistence to verify create/load snapshots, dynamic updates, scoped multi-session catalogs, supported-block flattening, direct success/error/failure, unknown-command isolation, cancellation, and the absence of model requests or session messages. The SDK helper suite pins direct-ACP composition. Keyless ACP and terminal snapshots pin the new protocol and rendered transcript shapes. @@ -60,6 +60,7 @@ TUI tests exercise all migrated built-ins, live plugin discovery, help/autocompl - **Represent human commands as model tools** — rejected because discovery and direct invocation are human UI behavior; routing through the model adds latency, token cost, and reinterpretation. - **Put the registry in the core agent spine** — rejected because headless and JSON-RPC agents do not consume it, while the two UI app bundles can compose it explicitly. - **Make `dsh-agent-loop` inject commands** — rejected because the loop does not execute or discover human commands. Agent-scoped producers declare the UI dependency in a child plugin instead. +- **Attach adapter masks to each definition** — rejected because support is a composition fact, not command-domain state. Every composed adapter exposes a registered command; an incompatible plugin omits registration in that deployment. - **Send unknown slash input to the model** — rejected because typoed or unavailable direct actions must fail predictably rather than change execution planes. - **Persist generic command input and output** — rejected because adapter notices are not model-visible state. A handler that changes durable behavior calls the owning domain API, which records its own events. - **Restrict ACP commands to one text block** — rejected because ACP v1 permits accompanying content; the bridge already has a lossless accepted-block translation. diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md index 7a2ed82eb1..3cd820c55e 100644 --- a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md @@ -16,9 +16,9 @@ TUI 拥有七个斜杠命令,而 ACP 定义了标准命令目录与调用形 ### 注册表契约 -`CommandDefinition` 包含不带 `/` 的小写名称、非空描述、可选的非结构化输入提示、可选的非空界面列表,以及可取消处理器。省略界面时解析为 `tui` 与 `acp`。注册会校验并分离元数据、冻结有效定义,并返回准确的 Cordis effect disposer(副作用释放器)。同一层中的重复名称会失败。 +`CommandDefinition` 包含不带 `/` 的小写名称、非空描述、可选的非结构化输入提示,以及可取消处理器。注册会校验并分离元数据、冻结有效定义,并返回准确的 Cordis effect disposer(副作用释放器)。同一层中的重复名称会失败。每个消费该注册表的适配器都能看到所有有效定义;若命令插件无法在某种部署中运行,它就不在该部署中注册,而不是把适配器身份编码进共享领域。 -`list(agent, surface)` 在界面过滤与作用域遮蔽后返回不可变、按名称排序的描述符。`find(agent, surface, name)` 解析有效定义。`execute(agent, surface, line, signal)` 解析并运行可见定义,返回分离后的 `success` 或 `error` 结果;无效语法、未知名称和对该界面隐藏的定义返回 `undefined`,由适配器拥有直接错误文本。 +`list(agent)` 在作用域遮蔽后返回不可变、按名称排序的描述符。`find(agent, name)` 解析有效定义。`execute(agent, line, signal)` 解析并运行已知定义,返回分离后的 `success` 或 `error` 结果;无效语法和未知名称返回 `undefined`,由适配器拥有直接错误文本。 `parseCommand(line)` 要求 `/` 位于第零字节,后接由字母、数字、`_` 或 `-` 组成的小写 ASCII 名称,并以空白或输入末尾结束。它把适配器交付的完整后缀保留为 `rawInput`,包括分隔空白。每个命令插件自行拥有后续语法决策。 @@ -30,13 +30,13 @@ TUI 拥有七个斜杠命令,而 ACP 定义了标准命令目录与调用形 ### 直接分派与取消 -命令在仅面向人类的命令平面中运行。输入不会成为 `user/message`,输出不会成为会话事件,两者都不会发送给模型。处理器接收准确的目标智能体、界面、原始输入和请求拥有的 `AbortSignal`。信号中止时,注册表不再等待不合作的处理器;处理器仍负责停止已经启动的外部副作用。 +命令在仅面向人类的命令平面中运行。输入不会成为 `user/message`,输出不会成为会话事件,两者都不会发送给模型。处理器接收准确的目标智能体、原始输入和请求拥有的 `AbortSignal`。信号中止时,注册表不再等待不合作的处理器;处理器仍负责停止已经启动的外部副作用。 预期的处理器失败返回 `CommandResult.error`。抛出的异常或格式错误的结果仍是适配器可见的命令失败,而不是模型消息。该边界有意分离 UI 输出与持久领域变更:例如目标命令可以改变 `ctx.goals`,但持久状态由目标服务拥有。 ### TUI 映射 -TUI 把 `help`、`clear`、`cancel`、`reasoning`、`tools`、`redraw` 和 `exit` 注册为智能体作用域命令定义,不再对字符串执行 switch。自动补全与帮助视图读取实时 `tui` 目录,因此插件命令会随其副作用出现和消失。任何以 `/` 开头的提交行都留在命令平面;未知输入产生终端警告,不会落入 `Agent.send()` 或 `Agent.steer()`。 +TUI 把 `help`、`clear`、`cancel`、`reasoning`、`tools`、`redraw` 和 `exit` 注册为智能体作用域命令定义,不再对字符串执行 switch。自动补全与帮助视图读取实时目录,因此插件命令会随其副作用出现和消失。任何以 `/` 开头的提交行都留在命令平面;未知输入产生终端警告,不会落入 `Agent.send()` 或 `Agent.steer()`。 每个提交的命令拥有一个 `AbortController`。TUI 释放会中止未完成的分派、移除本地定义,并等待命令生产者 fiber(纤程)后再完成清理。 @@ -50,7 +50,7 @@ ACP 允许命令提示携带额外的受支持内容块。桥接应用普通的 ## 测试 -注册表测试覆盖语法边界、不可变规范化、运行时元数据校验、默认和显式界面、确定性排序、全局与作用域遮蔽、重复拒绝、准确释放、变更通知失败隔离、直接调用、预期和格式错误结果、同步与异步失败,以及每种中止时序边沿;该源文件达到逐文件 100% 语句、分支、函数和行覆盖率。 +注册表测试覆盖语法边界、不可变规范化、运行时元数据校验、确定性排序、全局与作用域遮蔽、重复拒绝、准确释放、变更通知失败隔离、直接调用、预期和格式错误结果、同步与异步失败,以及每种中止时序边沿;该源文件达到逐文件 100% 语句、分支、函数和行覆盖率。 TUI 测试覆盖全部迁移后的内置命令、实时插件发现、帮助与自动补全刷新、直接结果、未知命令拒绝、原始输入交付、定义移除、启动回滚和释放取消。ACP 测试使用真实 SDK 连接、智能体工厂、循环与 JSONL 持久化,验证创建/加载快照、动态更新、作用域多会话目录、受支持块扁平化、直接成功/错误/失败、未知命令隔离、取消,以及不存在模型请求或会话消息。SDK helper 测试固定直接 ACP 组合。无密钥 ACP 与终端快照固定新的协议和渲染记录形态。 @@ -60,6 +60,7 @@ TUI 测试覆盖全部迁移后的内置命令、实时插件发现、帮助与 - **把人类命令表示为模型工具**——不予采纳,因为发现与直接调用属于人类 UI 行为;经由模型路由会增加延迟、token 成本和重新解释。 - **把注册表放入核心智能体主干**——不予采纳,因为无头和 JSON-RPC 智能体不消费它,而两个 UI 应用组合包可以显式组合它。 - **让 `dsh-agent-loop` 注入 commands**——不予采纳,因为循环不执行也不发现人类命令。智能体作用域生产者改为在子插件中声明 UI 依赖。 +- **为每个定义附加适配器掩码**——不予采纳,因为支持能力是组合事实,而不是命令领域状态。每个已组合适配器都暴露已注册命令;不兼容插件不会在该部署中注册。 - **把未知斜杠输入发送给模型**——不予采纳,因为输入错误或不可用的直接操作必须可预测地失败,而不能改变执行平面。 - **持久化通用命令输入与输出**——不予采纳,因为适配器提示不是模型可见状态。改变持久行为的处理器会调用拥有该状态的领域 API,由后者记录自己的事件。 - **把 ACP 命令限制为单个文本块**——不予采纳,因为 ACP v1 允许附带内容,而桥接已有无损的已接纳块转换。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index f05d78e9eb..e0f2e924f0 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -426,7 +426,7 @@ A command was registered or unregistered. This is an unfiltered registry notific 'commands/change'(): void ``` -Source: [`packages/ui/commands/src/index.ts:94`](../../packages/ui/commands/src/index.ts) +Source: [`packages/ui/commands/src/index.ts:83`](../../packages/ui/commands/src/index.ts) ## `fs/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index cac512b59d..85cadd1de1 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -347,42 +347,39 @@ Human-command registry. Plain-context definitions are global; definitions regist ```ts cordis-catalog /** * Register a global or calling-agent-scoped command. - * @param definition - discovery metadata, surface mask, and direct UI handler. + * @param definition - discovery metadata and direct UI handler. * @returns the exact effect disposer that unregisters this definition. */ register(definition: CommandDefinition): () => void /** - * List the effective immutable command descriptors for one agent and surface. + * List the effective immutable command descriptors for one agent. * @param agent - exact receiving agent and scoped-layer key. - * @param surface - UI adapter requesting discovery metadata. - * @returns name-sorted descriptors after scoped shadowing and surface filtering. + * @returns name-sorted descriptors after scoped shadowing. */ -list(agent: Agent, surface: CommandSurface): readonly CommandDescriptor[] +list(agent: Agent): readonly CommandDescriptor[] /** * Resolve one effective command definition. * @param agent - exact receiving agent and scoped-layer key. - * @param surface - UI adapter performing the lookup. * @param name - command name without a slash. - * @returns the scoped shadow or global definition when visible on the surface. + * @returns the scoped shadow or global definition. */ -find(agent: Agent, surface: CommandSurface, name: string): CommandDefinition | undefined +find(agent: Agent, name: string): CommandDefinition | undefined /** * Parse and execute a known command without sending it to the model. * @param agent - exact receiving agent. - * @param surface - dispatching UI adapter. * @param line - complete slash-command line. * @param signal - cancellation signal owned by the UI request. - * @returns a detached result, or `undefined` when syntax/name/surface does not resolve. + * @returns a detached result, or `undefined` when syntax or name does not resolve. */ -async execute( agent: Agent, surface: CommandSurface, line: string, signal: AbortSignal, ): Promise +async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise ``` -Types: [Agent](../core-data-structures/core.md) · [CommandDefinition](../core-data-structures/commands.md) · [CommandDescriptor](../core-data-structures/commands.md) · [CommandResult](../core-data-structures/commands.md) · [CommandSurface](../core-data-structures/commands.md) +Types: [Agent](../core-data-structures/core.md) · [CommandDefinition](../core-data-structures/commands.md) · [CommandDescriptor](../core-data-structures/commands.md) · [CommandResult](../core-data-structures/commands.md) -Source: [`packages/ui/commands/src/index.ts:235`](../../packages/ui/commands/src/index.ts) +Source: [`packages/ui/commands/src/index.ts:207`](../../packages/ui/commands/src/index.ts) ## `ctx.compact` — `CompactService` (abstract seam) diff --git a/docs/core-data-structures/commands.md b/docs/core-data-structures/commands.md index e8fd7ec497..c33b27ce1c 100644 --- a/docs/core-data-structures/commands.md +++ b/docs/core-data-structures/commands.md @@ -4,14 +4,9 @@ The human-command seam of [`dsh-commands`](../../packages/ui/commands). TUI and Source: [`packages/ui/commands/src/index.ts`](../../packages/ui/commands/src/index.ts) -## Surface and input metadata +## Input metadata -A definition selects one or more adapter identities. The shipped identities are `tui` and `acp`; the string intersection keeps the registry extensible without widening editor autocomplete to plain `string`. ACP currently exposes one unstructured-input hint. - -```ts type-equiv -/** A UI adapter capable of listing and executing human commands. */ -type CommandSurface = 'tui' | 'acp' | (string & {}) -``` +ACP currently exposes one unstructured-input hint. Command availability follows plugin composition: every adapter consuming the registry sees every effective definition. ```ts type-equiv /** Immutable command input metadata compatible with ACP unstructured input. */ @@ -23,7 +18,7 @@ interface CommandInputDescriptor { ## Definition -`CommandDefinition` is the plugin-authored registration. Omitted surfaces resolve to both shipped adapters; the registry validates and freezes a detached effective definition. +`CommandDefinition` is the plugin-authored registration. The registry validates and freezes a detached effective definition. ```ts type-equiv /** Plugin-owned command registration. */ @@ -34,8 +29,6 @@ interface CommandDefinition { readonly description: string /** Optional free-form input hint advertised to capable clients. */ readonly input?: CommandInputDescriptor - /** Surfaces exposing this command; omission means both shipped surfaces. */ - readonly surfaces?: readonly CommandSurface[] /** Execute against the receiving agent without sending the command to the model. */ readonly handler: (invocation: CommandInvocation) => CommandResult | Promise } @@ -50,8 +43,6 @@ The adapter owns cancellation and passes the exact target agent. `rawInput` begi interface CommandInvocation { /** Exact agent whose human-facing surface received the command. */ readonly agent: Agent - /** UI adapter that dispatched the command. */ - readonly surface: CommandSurface /** Exact text following the registered command name, including separator whitespace. */ readonly rawInput: string /** Cancellation signal owned by the dispatching UI request. */ @@ -68,7 +59,7 @@ type CommandResult = ## Discovery and parsing views -Adapters receive handler-free immutable descriptors after scope resolution and surface filtering. `parseCommand()` returns `ParsedCommand` before registry resolution; syntax-valid input can still name an unavailable command. +Adapters receive handler-free immutable descriptors after scope resolution. `parseCommand()` returns `ParsedCommand` before registry resolution; syntax-valid input can still name an unavailable command. ```ts type-equiv /** Handler-free immutable command view returned to UI adapters. */ @@ -79,8 +70,6 @@ interface CommandDescriptor { readonly description: string /** Optional free-form input hint advertised to capable clients. */ readonly input?: CommandInputDescriptor - /** Surfaces on which this definition is visible. */ - readonly surfaces: readonly CommandSurface[] } ``` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 9acbbbb348..0e02626b85 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -25,7 +25,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:302`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:312`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | -| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:94`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | +| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:83`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:53`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/glossary.md b/docs/glossary.md index ffb900d7b5..19daaa29dd 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -26,7 +26,6 @@ FIXME(glossary-completeness): Expand this glossary before the first release so i - **human command** — a slash-prefixed instruction interpreted and executed by a human-facing adapter through `ctx.commands`, without becoming a model message. It is distinct from a model-facing tool and from shell command execution through `ctx.bash`. - **command plane** — discovery, parsing, dispatch, cancellation, and result rendering owned by UI adapters and command plugins. Command output is UI state unless the handler separately mutates a durable domain. -- **command surface** — the adapter identity used to filter definitions, such as `tui` or `acp`; one scoped definition may shadow a same-named global command for its exact agent. ## loop hierarchy diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 8047af2834..b3701290d6 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -204,19 +204,19 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'register(definition: CommandDefinition): () => void', - jsDoc: '/**\n * Register a global or calling-agent-scoped command.\n * @param definition - discovery metadata, surface mask, and direct UI handler.\n * @returns the exact effect disposer that unregisters this definition.\n */', + jsDoc: '/**\n * Register a global or calling-agent-scoped command.\n * @param definition - discovery metadata and direct UI handler.\n * @returns the exact effect disposer that unregisters this definition.\n */', }, { - signature: 'list(agent: Agent, surface: CommandSurface): readonly CommandDescriptor[]', - jsDoc: '/**\n * List the effective immutable command descriptors for one agent and surface.\n * @param agent - exact receiving agent and scoped-layer key.\n * @param surface - UI adapter requesting discovery metadata.\n * @returns name-sorted descriptors after scoped shadowing and surface filtering.\n */', + signature: 'list(agent: Agent): readonly CommandDescriptor[]', + jsDoc: '/**\n * List the effective immutable command descriptors for one agent.\n * @param agent - exact receiving agent and scoped-layer key.\n * @returns name-sorted descriptors after scoped shadowing.\n */', }, { - signature: 'find(agent: Agent, surface: CommandSurface, name: string): CommandDefinition | undefined', - jsDoc: '/**\n * Resolve one effective command definition.\n * @param agent - exact receiving agent and scoped-layer key.\n * @param surface - UI adapter performing the lookup.\n * @param name - command name without a slash.\n * @returns the scoped shadow or global definition when visible on the surface.\n */', + signature: 'find(agent: Agent, name: string): CommandDefinition | undefined', + jsDoc: '/**\n * Resolve one effective command definition.\n * @param agent - exact receiving agent and scoped-layer key.\n * @param name - command name without a slash.\n * @returns the scoped shadow or global definition.\n */', }, { - signature: 'async execute( agent: Agent, surface: CommandSurface, line: string, signal: AbortSignal, ): Promise', - jsDoc: '/**\n * Parse and execute a known command without sending it to the model.\n * @param agent - exact receiving agent.\n * @param surface - dispatching UI adapter.\n * @param line - complete slash-command line.\n * @param signal - cancellation signal owned by the UI request.\n * @returns a detached result, or `undefined` when syntax/name/surface does not resolve.\n */', + signature: 'async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise', + jsDoc: '/**\n * Parse and execute a known command without sending it to the model.\n * @param agent - exact receiving agent.\n * @param line - complete slash-command line.\n * @param signal - cancellation signal owned by the UI request.\n * @returns a detached result, or `undefined` when syntax or name does not resolve.\n */', }, ], }, @@ -1127,11 +1127,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CommandDefinition', - declaration: 'export interface CommandDefinition {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly surfaces?: readonly CommandSurface[];\n readonly handler: (invocation: CommandInvocation) => CommandResult | Promise;\n}', + declaration: 'export interface CommandDefinition {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly handler: (invocation: CommandInvocation) => CommandResult | Promise;\n}', }, { name: 'CommandDescriptor', - declaration: 'export interface CommandDescriptor {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly surfaces: readonly CommandSurface[];\n}', + declaration: 'export interface CommandDescriptor {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n}', }, { name: 'CommandInputDescriptor', @@ -1139,16 +1139,12 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CommandInvocation', - declaration: 'export interface CommandInvocation {\n readonly agent: Agent;\n readonly surface: CommandSurface;\n readonly rawInput: string;\n readonly signal: AbortSignal;\n}', + declaration: 'export interface CommandInvocation {\n readonly agent: Agent;\n readonly rawInput: string;\n readonly signal: AbortSignal;\n}', }, { name: 'CommandResult', declaration: 'export type CommandResult = {\n readonly kind: \'success\';\n readonly text?: string;\n} | {\n readonly kind: \'error\';\n readonly text: string;\n};', }, - { - name: 'CommandSurface', - declaration: 'export type CommandSurface = \'tui\' | \'acp\' | (string & {});', - }, { name: 'CompactAgentContext', declaration: 'export interface CompactAgentContext {\n session: Session;\n options: {\n provider?: string;\n model?: string;\n };\n}', diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 459dc50a43..2f075a7ccc 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -514,7 +514,7 @@ export function apply(ctx: Context, config: AcpConfig): void { } /** Project the effective registry view onto ACP discovery metadata. */ - const availableCommands = (agent: Agent): AvailableCommand[] => commands.list(agent, 'acp').map(command => ({ + const availableCommands = (agent: Agent): AvailableCommand[] => commands.list(agent).map(command => ({ name: command.name, description: command.description, ...command.input === undefined ? {} : { input: { hint: command.input.hint } }, @@ -905,7 +905,7 @@ export function apply(ctx: Context, config: AcpConfig): void { const controller = new AbortController() rec.commandAbort = controller try { - const result = await commands.execute(rec.agent, 'acp', commandLine, controller.signal) + const result = await commands.execute(rec.agent, commandLine, controller.signal) if (result !== undefined && result.text !== undefined && result.text !== '') { notify({ sessionId: rec.agent.session.id, diff --git a/packages/ui/acp/tests/commands.spec.ts b/packages/ui/acp/tests/commands.spec.ts index 946ee67950..71aae1ea64 100644 --- a/packages/ui/acp/tests/commands.spec.ts +++ b/packages/ui/acp/tests/commands.spec.ts @@ -55,7 +55,6 @@ describe('ACP plugin commands', () => { const dispose = harness.ctx.commands.register({ name: 'alpha', description: 'Alpha command', - surfaces: ['acp'], handler: () => ({ kind: 'success' }), }) await vi.waitFor(() => { @@ -126,7 +125,7 @@ describe('ACP plugin commands', () => { }) expect(response.stopReason).toBe('end_turn') - expect(seen).toHaveBeenCalledWith(expect.objectContaining({ surface: 'acp', rawInput: ' raw args ' })) + expect(seen).toHaveBeenCalledWith(expect.objectContaining({ rawInput: ' raw args ' })) expect(messageText(harness, sessionId)).toContain('DIRECT RESULT') const updatesAfterText = harness.sessionUpdates.length await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/silent' }] }) @@ -260,7 +259,7 @@ describe('ACP plugin commands', () => { if (agentA === undefined) throw new Error('session A has no agent') await agentA.ctx.inject(['commands'], (commandCtx) => { commandCtx.commands.register({ - name: 'private', description: 'Only session A', surfaces: ['acp'], + name: 'private', description: 'Only session A', handler: () => ({ kind: 'success', text: 'A ONLY' }), }) }) diff --git a/packages/ui/commands/README.md b/packages/ui/commands/README.md index 5b6c8b9425..6e0efa458b 100644 --- a/packages/ui/commands/README.md +++ b/packages/ui/commands/README.md @@ -4,9 +4,9 @@ Plugin-owned human-command registry shared by the TUI and ACP adapters. The [plu ## Service contract -`ctx.commands.register(definition)` registers one lowercase command name, description, optional ACP-compatible unstructured-input hint, optional surface list, and abortable handler. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers. +`ctx.commands.register(definition)` registers one lowercase command name, description, optional ACP-compatible unstructured-input hint, and abortable handler. A registered command is available to every composed command adapter; a plugin that is incompatible with a deployment does not register there. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers. -`list(agent, surface)` returns immutable, name-sorted descriptors after scoped shadowing and surface filtering. `find(agent, surface, name)` returns the corresponding definition. `execute(agent, surface, line, signal)` uses `parseCommand()` and runs only a known command, returning `undefined` for invalid syntax, unknown names, or commands hidden from that surface. +`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning `undefined` for invalid syntax or unknown names. `parseCommand()` recognizes a slash at byte zero, a lowercase name containing letters, digits, `_`, or `-`, and either end-of-input or whitespace. It returns every byte after the name as `rawInput`, including separator whitespace; consumers own their command-specific grammar and may normalize only what that grammar permits. diff --git a/packages/ui/commands/src/index.ts b/packages/ui/commands/src/index.ts index 529dea11b4..16e665e71f 100644 --- a/packages/ui/commands/src/index.ts +++ b/packages/ui/commands/src/index.ts @@ -11,11 +11,6 @@ import type { ScopeKey } from '@deepseek-ai/dsh-scope' export const name = 'commands' const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u -const SURFACE_NAME = /^[a-z][a-z0-9-]*$/u -const DEFAULT_SURFACES = ['tui', 'acp'] as const - -/** A UI adapter capable of listing and executing human commands. */ -export type CommandSurface = 'tui' | 'acp' | (string & {}) /** Immutable command input metadata compatible with ACP unstructured input. */ export interface CommandInputDescriptor { @@ -27,8 +22,6 @@ export interface CommandInputDescriptor { export interface CommandInvocation { /** Exact agent whose human-facing surface received the command. */ readonly agent: Agent - /** UI adapter that dispatched the command. */ - readonly surface: CommandSurface /** Exact text following the registered command name, including separator whitespace. */ readonly rawInput: string /** Cancellation signal owned by the dispatching UI request. */ @@ -48,8 +41,6 @@ export interface CommandDefinition { readonly description: string /** Optional free-form input hint advertised to capable clients. */ readonly input?: CommandInputDescriptor - /** Surfaces exposing this command; omission means both shipped surfaces. */ - readonly surfaces?: readonly CommandSurface[] /** Execute against the receiving agent without sending the command to the model. */ readonly handler: (invocation: CommandInvocation) => CommandResult | Promise } @@ -62,8 +53,6 @@ export interface CommandDescriptor { readonly description: string /** Optional free-form input hint advertised to capable clients. */ readonly input?: CommandInputDescriptor - /** Surfaces on which this definition is visible. */ - readonly surfaces: readonly CommandSurface[] } /** Syntactically valid slash command before registry resolution. */ @@ -75,7 +64,7 @@ export interface ParsedCommand { } interface RegisteredCommand { - readonly definition: CommandDefinition & { readonly surfaces: readonly CommandSurface[] } + readonly definition: CommandDefinition readonly descriptor: CommandDescriptor } @@ -175,33 +164,16 @@ function normalizeDefinition(definition: CommandDefinition): RegisteredCommand { } input = Object.freeze({ hint: rawInput.hint }) } - const surfaces = [...(definition.surfaces ?? DEFAULT_SURFACES)] - if (surfaces.length === 0) { - throw new TypeError(`command "${definition.name}" must expose at least one surface`) - } - const unique = new Set() - for (const surface of surfaces) { - if (!SURFACE_NAME.test(surface)) { - throw new TypeError(`command "${definition.name}" surface "${surface}" must match ${String(SURFACE_NAME)}`) - } - if (unique.has(surface)) { - throw new TypeError(`command "${definition.name}" surface "${surface}" is duplicated`) - } - unique.add(surface) - } - const frozenSurfaces = Object.freeze(surfaces) const normalized = Object.freeze({ name: definition.name, description: definition.description, ...input === undefined ? {} : { input }, - surfaces: frozenSurfaces, handler: definition.handler, }) const descriptor = Object.freeze({ name: normalized.name, description: normalized.description, ...normalized.input === undefined ? {} : { input: normalized.input }, - surfaces: normalized.surfaces, }) return { definition: normalized, descriptor } } @@ -242,7 +214,7 @@ export class CommandService extends Service { /** * Register a global or calling-agent-scoped command. - * @param definition - discovery metadata, surface mask, and direct UI handler. + * @param definition - discovery metadata and direct UI handler. * @returns the exact effect disposer that unregisters this definition. */ register(definition: CommandDefinition): () => void { @@ -268,14 +240,12 @@ export class CommandService extends Service { } /** - * List the effective immutable command descriptors for one agent and surface. + * List the effective immutable command descriptors for one agent. * @param agent - exact receiving agent and scoped-layer key. - * @param surface - UI adapter requesting discovery metadata. - * @returns name-sorted descriptors after scoped shadowing and surface filtering. + * @returns name-sorted descriptors after scoped shadowing. */ - list(agent: Agent, surface: CommandSurface): readonly CommandDescriptor[] { + list(agent: Agent): readonly CommandDescriptor[] { return Object.freeze([...this.view(agent).values()] - .filter(command => command.definition.surfaces.includes(surface)) .map(command => command.descriptor) // Names are unique in the effective view, so equality is impossible. .sort((left, right) => left.name < right.name ? -1 : 1)) @@ -284,35 +254,31 @@ export class CommandService extends Service { /** * Resolve one effective command definition. * @param agent - exact receiving agent and scoped-layer key. - * @param surface - UI adapter performing the lookup. * @param name - command name without a slash. - * @returns the scoped shadow or global definition when visible on the surface. + * @returns the scoped shadow or global definition. */ - find(agent: Agent, surface: CommandSurface, name: string): CommandDefinition | undefined { - const command = this.view(agent).get(name) - return command?.definition.surfaces.includes(surface) === true ? command.definition : undefined + find(agent: Agent, name: string): CommandDefinition | undefined { + return this.view(agent).get(name)?.definition } /** * Parse and execute a known command without sending it to the model. * @param agent - exact receiving agent. - * @param surface - dispatching UI adapter. * @param line - complete slash-command line. * @param signal - cancellation signal owned by the UI request. - * @returns a detached result, or `undefined` when syntax/name/surface does not resolve. + * @returns a detached result, or `undefined` when syntax or name does not resolve. */ async execute( agent: Agent, - surface: CommandSurface, line: string, signal: AbortSignal, ): Promise { const parsed = parseCommand(line) if (parsed === undefined) return undefined const command = this.view(agent).get(parsed.name) - if (command === undefined || !command.definition.surfaces.includes(surface)) return undefined + if (command === undefined) return undefined if (signal.aborted) throw abortError(signal) - const invocation = Object.freeze({ agent, surface, rawInput: parsed.rawInput, signal }) + const invocation = Object.freeze({ agent, rawInput: parsed.rawInput, signal }) const output = command.definition.handler(invocation) return normalizeResult(parsed.name, await withAbort(Promise.resolve(output), signal)) } diff --git a/packages/ui/commands/tests/commands.spec.ts b/packages/ui/commands/tests/commands.spec.ts index e3da186492..839ccb0297 100644 --- a/packages/ui/commands/tests/commands.spec.ts +++ b/packages/ui/commands/tests/commands.spec.ts @@ -44,7 +44,7 @@ describe('parseCommand()', () => { }) describe('CommandService', () => { - it('lists immutable global descriptors with default surfaces and ACP input metadata', async () => { + it('lists immutable global descriptors with input metadata', async () => { const ctx = await mount() const { agent } = await mintAgentScope(ctx, 'a') const definition: CommandDefinition = { @@ -55,20 +55,17 @@ describe('CommandService', () => { } ctx.commands.register(definition) - const listed = ctx.commands.list(agent, 'acp') + const listed = ctx.commands.list(agent) expect(listed).toEqual([{ name: 'inspect', description: 'Inspect state', input: { hint: '' }, - surfaces: ['tui', 'acp'], }]) expect(Object.isFrozen(listed)).toBe(true) expect(Object.isFrozen(listed[0])).toBe(true) expect(Object.isFrozen(listed[0]?.input)).toBe(true) - expect(Object.isFrozen(listed[0]?.surfaces)).toBe(true) - expect(ctx.commands.find(agent, 'tui', 'inspect')).toMatchObject({ name: 'inspect' }) - expect(ctx.commands.find(agent, 'other', 'inspect')).toBeUndefined() - expect(ctx.commands.find(agent, 'tui', 'missing')).toBeUndefined() + expect(ctx.commands.find(agent, 'inspect')).toMatchObject({ name: 'inspect' }) + expect(ctx.commands.find(agent, 'missing')).toBeUndefined() }) it('sorts distinct effective command names', async () => { @@ -77,7 +74,7 @@ describe('CommandService', () => { ctx.commands.register(command('zeta')) ctx.commands.register(command('alpha')) ctx.commands.register(command('middle')) - expect(ctx.commands.list(agent, 'tui').map(item => item.name)).toEqual(['alpha', 'middle', 'zeta']) + expect(ctx.commands.list(agent).map(item => item.name)).toEqual(['alpha', 'middle', 'zeta']) }) it('uses agent-scoped shadows and removes them with their scope', async () => { @@ -85,17 +82,16 @@ describe('CommandService', () => { const { scope, agent } = await mintAgentScope(ctx, 'a') const other = { id: 'other' as SessionId } as Agent ctx.commands.register(command('shared', 'global')) - scope.ctx.commands.register({ ...command('shared', 'scoped'), surfaces: ['tui'] }) + scope.ctx.commands.register(command('shared', 'scoped')) - expect(ctx.commands.list(agent, 'tui').map(item => item.name)).toEqual(['shared']) - expect(ctx.commands.list(agent, 'acp')).toEqual([]) - expect(ctx.commands.find(agent, 'tui', 'shared')?.handler).toBeDefined() - expect(ctx.commands.list(other, 'acp').map(item => item.name)).toEqual(['shared']) - expect(await ctx.commands.execute(agent, 'tui', '/shared', new AbortController().signal)) + expect(ctx.commands.list(agent).map(item => item.name)).toEqual(['shared']) + expect(ctx.commands.find(agent, 'shared')?.handler).toBeDefined() + expect(ctx.commands.list(other).map(item => item.name)).toEqual(['shared']) + expect(await ctx.commands.execute(agent, '/shared', new AbortController().signal)) .toEqual({ kind: 'success', text: 'scoped' }) await scope.dispose() - expect((await ctx.commands.execute(agent, 'tui', '/shared', new AbortController().signal))?.text).toBe('global') + expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.text).toBe('global') }) it('rejects duplicates within one layer while allowing a scoped shadow', async () => { @@ -124,14 +120,14 @@ describe('CommandService', () => { ctx.on('commands/change', afterFailures) const removeContained = ctx.commands.register(command('contained')) const { agent } = await mintAgentScope(ctx, 'a') - expect(ctx.commands.find(agent, 'tui', 'contained')).toBeDefined() + expect(ctx.commands.find(agent, 'contained')).toBeDefined() expect(afterFailures).toHaveBeenCalledTimes(1) await vi.waitFor(() => { expect(warn).toHaveBeenCalledWith('commands/change listener threw: Error: observer threw') expect(warn).toHaveBeenCalledWith('commands/change listener rejected: Error: observer rejected') }) removeContained() - expect(ctx.commands.find(agent, 'tui', 'contained')).toBeUndefined() + expect(ctx.commands.find(agent, 'contained')).toBeUndefined() expect(afterFailures).toHaveBeenCalledTimes(2) }) @@ -155,22 +151,20 @@ describe('CommandService', () => { const ctx = await mount() const { agent } = await mintAgentScope(ctx, 'a') const seen = vi.fn(() => ({ kind: 'success' as const, text: 'ok' })) - ctx.commands.register({ name: 'run', description: 'Run it', surfaces: ['acp'], handler: seen }) + ctx.commands.register({ name: 'run', description: 'Run it', handler: seen }) const controller = new AbortController() - const result = await ctx.commands.execute(agent, 'acp', '/run untouched ', controller.signal) + const result = await ctx.commands.execute(agent, '/run untouched ', controller.signal) expect(result).toEqual({ kind: 'success', text: 'ok' }) expect(Object.isFrozen(result)).toBe(true) expect(seen).toHaveBeenCalledWith(expect.objectContaining({ agent, - surface: 'acp', rawInput: ' untouched ', signal: controller.signal, })) - await expect(ctx.commands.execute(agent, 'tui', '/run', controller.signal)).resolves.toBeUndefined() - await expect(ctx.commands.execute(agent, 'acp', 'run', controller.signal)).resolves.toBeUndefined() - await expect(ctx.commands.execute(agent, 'acp', '/missing', controller.signal)).resolves.toBeUndefined() + await expect(ctx.commands.execute(agent, 'run', controller.signal)).resolves.toBeUndefined() + await expect(ctx.commands.execute(agent, '/missing', controller.signal)).resolves.toBeUndefined() }) it('stops awaiting an aborted handler and handles an already-aborted signal', async () => { @@ -183,18 +177,18 @@ describe('CommandService', () => { handler: () => new Promise((resolve) => { release = resolve }), }) const running = new AbortController() - const promise = ctx.commands.execute(agent, 'tui', '/wait', running.signal) + const promise = ctx.commands.execute(agent, '/wait', running.signal) running.abort('operator cancelled command') await expect(promise).rejects.toThrow('operator cancelled command') release({ kind: 'success', text: 'late' }) const already = new AbortController() already.abort(new Error('already gone')) - await expect(ctx.commands.execute(agent, 'tui', '/wait', already.signal)).rejects.toThrow('already gone') + await expect(ctx.commands.execute(agent, '/wait', already.signal)).rejects.toThrow('already gone') const defaultReason = new AbortController() defaultReason.abort({ source: 'test' }) - await expect(ctx.commands.execute(agent, 'tui', '/wait', defaultReason.signal)).rejects.toThrow('command aborted') + await expect(ctx.commands.execute(agent, '/wait', defaultReason.signal)).rejects.toThrow('command aborted') }) it('propagates an asynchronously rejected handler', async () => { @@ -205,7 +199,7 @@ describe('CommandService', () => { description: 'Reject', handler: () => Promise.reject(new Error('handler rejected')), }) - await expect(ctx.commands.execute(agent, 'tui', '/reject', new AbortController().signal)) + await expect(ctx.commands.execute(agent, '/reject', new AbortController().signal)) .rejects.toThrow('handler rejected') ctx.commands.register({ @@ -214,7 +208,7 @@ describe('CommandService', () => { // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise untyped plugin normalization handler: () => Promise.reject('not an Error'), }) - await expect(ctx.commands.execute(agent, 'tui', '/reject-value', new AbortController().signal)) + await expect(ctx.commands.execute(agent, '/reject-value', new AbortController().signal)) .rejects.toThrow('command handler rejected with a non-Error value: not an Error') const hostile = { toString(): string { throw new Error('cannot render') } } @@ -224,7 +218,7 @@ describe('CommandService', () => { // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise hostile plugin normalization handler: () => Promise.reject(hostile), }) - await expect(ctx.commands.execute(agent, 'tui', '/reject-hostile', new AbortController().signal)) + await expect(ctx.commands.execute(agent, '/reject-hostile', new AbortController().signal)) .rejects.toMatchObject({ message: 'command handler rejected with a non-Error value: ', cause: hostile, @@ -243,7 +237,7 @@ describe('CommandService', () => { return { kind: 'success' } }, }) - await expect(ctx.commands.execute(agent, 'tui', '/self-abort', controller.signal)) + await expect(ctx.commands.execute(agent, '/self-abort', controller.signal)) .rejects.toThrow('aborted in handler') }) @@ -255,7 +249,7 @@ describe('CommandService', () => { description: 'Denied', handler: () => ({ kind: 'error', text: 'not now' }), }) - const result = await ctx.commands.execute(agent, 'tui', '/denied', new AbortController().signal) + const result = await ctx.commands.execute(agent, '/denied', new AbortController().signal) expect(result).toEqual({ kind: 'error', text: 'not now' }) expect(Object.isFrozen(result)).toBe(true) @@ -264,7 +258,7 @@ describe('CommandService', () => { description: 'No output', handler: () => ({ kind: 'success' }), }) - const silent = await ctx.commands.execute(agent, 'tui', '/silent', new AbortController().signal) + const silent = await ctx.commands.execute(agent, '/silent', new AbortController().signal) expect(silent).toEqual({ kind: 'success' }) expect(Object.isFrozen(silent)).toBe(true) }) @@ -273,9 +267,6 @@ describe('CommandService', () => { [{ ...command('Bad') }, /command name/], [{ ...command('empty-description'), description: ' ' }, /description/], [{ ...command('empty-hint'), input: { hint: '' } }, /input hint/], - [{ ...command('no-surface'), surfaces: [] }, /at least one surface/], - [{ ...command('bad-surface'), surfaces: ['ACP'] }, /surface/], - [{ ...command('duplicate-surface'), surfaces: ['tui', 'tui'] }, /duplicated/], [{ ...command('bad-handler'), handler: undefined }, /handler/], ] as const)('rejects invalid definition %#', async (definition, expected) => { const ctx = await mount() @@ -298,6 +289,6 @@ describe('CommandService', () => { description: 'Broken', handler: () => output as never, }) - await expect(ctx.commands.execute(agent, 'tui', '/broken', new AbortController().signal)).rejects.toThrow(expected) + await expect(ctx.commands.execute(agent, '/broken', new AbortController().signal)).rejects.toThrow(expected) }) }) diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 2fe7398931..83432b91d4 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -1144,7 +1144,7 @@ export function createTuiChat( } const showHelp = (): void => { - const commandLines = ctx.commands.list(agent, 'tui').map((command) => { + const commandLines = ctx.commands.list(agent).map((command) => { const input = command.input === undefined ? '' : ` ${command.input.hint}` return `/${command.name}${input} — ${command.description}` }) @@ -1162,7 +1162,7 @@ export function createTuiChat( const refreshCommandAutocomplete = (): void => { editor.setAutocompleteProvider(new CombinedAutocompleteProvider( - ctx.commands.list(agent, 'tui').map(command => ({ + ctx.commands.list(agent).map(command => ({ name: command.name, description: command.description, })), @@ -1179,19 +1179,16 @@ export function createTuiChat( commandCtx.commands.register({ name: 'help', description: 'Show keyboard shortcuts and commands', - surfaces: ['tui'], handler: () => { showHelp(); return { kind: 'success' } }, }) commandCtx.commands.register({ name: 'clear', description: 'Clear the transcript view (session history is unchanged)', - surfaces: ['tui'], handler: () => { chat.clear(); requestRender(); return { kind: 'success' } }, }) commandCtx.commands.register({ name: 'cancel', description: 'Cancel the active turn', - surfaces: ['tui'], handler: () => { if (agent.status !== 'running') return { kind: 'error', text: 'The agent is already idle.' } agent.cancel('cancelled from terminal') @@ -1201,25 +1198,21 @@ export function createTuiChat( commandCtx.commands.register({ name: 'reasoning', description: 'Toggle reasoning blocks', - surfaces: ['tui'], handler: () => { toggleReasoning(); return { kind: 'success' } }, }) commandCtx.commands.register({ name: 'tools', description: 'Expand or collapse all tool cards', - surfaces: ['tui'], handler: () => { toggleTools(); return { kind: 'success' } }, }) commandCtx.commands.register({ name: 'redraw', description: 'Invalidate components and redraw the terminal', - surfaces: ['tui'], handler: () => { ui.invalidate(); ui.requestRender(true); return { kind: 'success' } }, }) commandCtx.commands.register({ name: 'exit', description: 'Exit after the active turn reaches idle', - surfaces: ['tui'], handler: () => { requestExit(); return { kind: 'success' } }, }) }) @@ -1227,7 +1220,7 @@ export function createTuiChat( const runCommand = (text: string): void => { const controller = new AbortController() commandControllers.add(controller) - void ctx.commands.execute(agent, 'tui', text, controller.signal).then( + void ctx.commands.execute(agent, text, controller.signal).then( (result) => { if (disposed) return if (result === undefined) { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index be67e117cd..50280d61b9 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -442,13 +442,11 @@ describe('pi-tui chat lifecycle and transcript', () => { name: 'plugin-check', description: 'Run a plugin command', input: { hint: '' }, - surfaces: ['tui'], handler, }) result.ctx.commands.register({ name: 'plugin-fail', description: 'Fail a plugin command', - surfaces: ['tui'], handler: () => { throw new Error('plugin command exploded') }, }) @@ -459,7 +457,6 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(handler).toHaveBeenCalledTimes(1) const invocation = handler.mock.calls[0]?.[0] expect(invocation?.agent).toBe(result.agent) - expect(invocation?.surface).toBe('tui') // pi-tui's Editor owns terminal-line normalization and removes trailing // spaces before onSubmit; the registry preserves the adapter-delivered line. expect(invocation?.rawInput).toBe(' value') @@ -472,10 +469,10 @@ describe('pi-tui chat lifecycle and transcript', () => { result.terminal.send('\r') await tick() expect(result.terminal.output).toContain('/plugin-check — Run a plugin command') - expect(result.ctx.commands.list(result.agent, 'tui').map(command => command.name)).toContain('help') + expect(result.ctx.commands.list(result.agent).map(command => command.name)).toContain('help') await result.controller.dispose() - expect(result.ctx.commands.list(result.agent, 'tui').map(command => command.name)).toEqual([ + expect(result.ctx.commands.list(result.agent).map(command => command.name)).toEqual([ 'plugin-check', 'plugin-fail', ]) @@ -490,7 +487,6 @@ describe('pi-tui chat lifecycle and transcript', () => { result.ctx.commands.register({ name: 'wait-plugin', description: 'Wait until disposal', - surfaces: ['tui'], handler: ({ signal }) => { commandSignal = signal started() @@ -518,7 +514,6 @@ describe('pi-tui chat lifecycle and transcript', () => { result.ctx.commands.register({ name: 'late-success', description: 'Resolve while the TUI closes', - surfaces: ['tui'], handler: () => new Promise((resolve) => { resolveCommand = resolve started() @@ -1027,7 +1022,7 @@ describe('terminal mounting', () => { expect(() => createTuiChat(ctx, { sessionId: 'failed-start-session', color: false }, { terminal, exit: vi.fn() })) .toThrow('terminal startup failed') await tick() - expect(ctx.commands.list(ctx.agents.get(SessionId('failed-start-session'))!, 'tui')).toEqual([]) + expect(ctx.commands.list(ctx.agents.get(SessionId('failed-start-session'))!)).toEqual([]) expect(terminal.stopped).toBe(1) expect(terminal.progress).toEqual([false, true, false]) await expect(ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Late?' }] })) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 02755f19ae..83005c9bb8 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -39,7 +39,6 @@ { "doc": "docs/core-data-structures/goal.md", "symbol": "EditGoalRequest", "source": "packages/goal/goal/src/types.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalChanged", "source": "packages/goal/goal/src/types.ts" }, - { "doc": "docs/core-data-structures/commands.md", "symbol": "CommandSurface", "source": "packages/ui/commands/src/index.ts" }, { "doc": "docs/core-data-structures/commands.md", "symbol": "CommandInputDescriptor", "source": "packages/ui/commands/src/index.ts" }, { "doc": "docs/core-data-structures/commands.md", "symbol": "CommandDefinition", "source": "packages/ui/commands/src/index.ts" }, { "doc": "docs/core-data-structures/commands.md", "symbol": "CommandInvocation", "source": "packages/ui/commands/src/index.ts" }, diff --git a/website/zh-CN/api/harness/commands.md b/website/zh-CN/api/harness/commands.md index e59a2203da..3b92682dac 100644 --- a/website/zh-CN/api/harness/commands.md +++ b/website/zh-CN/api/harness/commands.md @@ -6,14 +6,14 @@ Human-command registry. Plain-context definitions are global; definitions registered through a command-injected child of an agent context shadow globals for that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L235) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L207) ### ctx.commands.register(definition) ```ts website-api /** * Register a global or calling-agent-scoped command. - * @param definition - discovery metadata, surface mask, and direct UI handler. + * @param definition - discovery metadata and direct UI handler. * @returns the exact effect disposer that unregisters this definition. */ register(definition: CommandDefinition): () => void @@ -21,77 +21,71 @@ register(definition: CommandDefinition): () => void Register a global or calling-agent-scoped command. -- `definition` — discovery metadata, surface mask, and direct UI handler. +- `definition` — discovery metadata and direct UI handler. **Returns** the exact effect disposer that unregisters this definition. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L248) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L220) -### ctx.commands.list(agent, surface) +### ctx.commands.list(agent) ```ts website-api /** - * List the effective immutable command descriptors for one agent and surface. + * List the effective immutable command descriptors for one agent. * @param agent - exact receiving agent and scoped-layer key. - * @param surface - UI adapter requesting discovery metadata. - * @returns name-sorted descriptors after scoped shadowing and surface filtering. + * @returns name-sorted descriptors after scoped shadowing. */ -list(agent: Agent, surface: CommandSurface): readonly CommandDescriptor[] +list(agent: Agent): readonly CommandDescriptor[] ``` -List the effective immutable command descriptors for one agent and surface. +List the effective immutable command descriptors for one agent. - `agent` — exact receiving agent and scoped-layer key. -- `surface` — UI adapter requesting discovery metadata. -**Returns** name-sorted descriptors after scoped shadowing and surface filtering. +**Returns** name-sorted descriptors after scoped shadowing. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L276) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L247) -### ctx.commands.find(agent, surface, name) +### ctx.commands.find(agent, name) ```ts website-api /** * Resolve one effective command definition. * @param agent - exact receiving agent and scoped-layer key. - * @param surface - UI adapter performing the lookup. * @param name - command name without a slash. - * @returns the scoped shadow or global definition when visible on the surface. + * @returns the scoped shadow or global definition. */ -find(agent: Agent, surface: CommandSurface, name: string): CommandDefinition | undefined +find(agent: Agent, name: string): CommandDefinition | undefined ``` Resolve one effective command definition. - `agent` — exact receiving agent and scoped-layer key. -- `surface` — UI adapter performing the lookup. - `name` — command name without a slash. -**Returns** the scoped shadow or global definition when visible on the surface. +**Returns** the scoped shadow or global definition. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L291) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L260) -### ctx.commands.execute(agent, surface, line, signal) +### ctx.commands.execute(agent, line, signal) ```ts website-api /** * Parse and execute a known command without sending it to the model. * @param agent - exact receiving agent. - * @param surface - dispatching UI adapter. * @param line - complete slash-command line. * @param signal - cancellation signal owned by the UI request. - * @returns a detached result, or `undefined` when syntax/name/surface does not resolve. + * @returns a detached result, or `undefined` when syntax or name does not resolve. */ -async execute( agent: Agent, surface: CommandSurface, line: string, signal: AbortSignal, ): Promise +async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise ``` Parse and execute a known command without sending it to the model. - `agent` — exact receiving agent. -- `surface` — dispatching UI adapter. - `line` — complete slash-command line. - `signal` — cancellation signal owned by the UI request. -**Returns** a detached result, or `undefined` when syntax/name/surface does not resolve. +**Returns** a detached result, or `undefined` when syntax or name does not resolve. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L304) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L271) diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index 18f202f773..e9266114b1 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -490,7 +490,7 @@ Ask composed answerers for one decision. Return an outcome to claim the request A command was registered or unregistered. This is an unfiltered registry notification because a global or scoped change may affect any UI view. Observer failures are contained and cannot veto the registry mutation. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L94) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L83) ## fs/* From 4e747942360e9800b3c6a2f570bdd76fde0814f0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:44:57 +0800 Subject: [PATCH 17/25] test(goal): isolate the ACP lifecycle snapshot --- .../goal-session/input.json | 0 .../goal-session/replay.override.json | 0 .../goal-session/session.expected.jsonl | 0 .../goal-session/session.jsonl | 0 .../goal-session/stdout.expected.jsonl | 0 examples/acp-agent/tests/goal.snapshot.ts | 4 +++- knip.json | 2 +- packages/goal/goal-session/package.json | 1 - pnpm-lock.yaml | 9 +++------ 9 files changed, 7 insertions(+), 9 deletions(-) rename examples/acp-agent/tests/{snapshots => goal-snapshots}/goal-session/input.json (100%) rename examples/acp-agent/tests/{snapshots => goal-snapshots}/goal-session/replay.override.json (100%) rename examples/acp-agent/tests/{snapshots => goal-snapshots}/goal-session/session.expected.jsonl (100%) rename examples/acp-agent/tests/{snapshots => goal-snapshots}/goal-session/session.jsonl (100%) rename examples/acp-agent/tests/{snapshots => goal-snapshots}/goal-session/stdout.expected.jsonl (100%) diff --git a/examples/acp-agent/tests/snapshots/goal-session/input.json b/examples/acp-agent/tests/goal-snapshots/goal-session/input.json similarity index 100% rename from examples/acp-agent/tests/snapshots/goal-session/input.json rename to examples/acp-agent/tests/goal-snapshots/goal-session/input.json diff --git a/examples/acp-agent/tests/snapshots/goal-session/replay.override.json b/examples/acp-agent/tests/goal-snapshots/goal-session/replay.override.json similarity index 100% rename from examples/acp-agent/tests/snapshots/goal-session/replay.override.json rename to examples/acp-agent/tests/goal-snapshots/goal-session/replay.override.json diff --git a/examples/acp-agent/tests/snapshots/goal-session/session.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/goal-session/session.expected.jsonl rename to examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/goal-session/session.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-session/session.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/goal-session/session.jsonl rename to examples/acp-agent/tests/goal-snapshots/goal-session/session.jsonl diff --git a/examples/acp-agent/tests/snapshots/goal-session/stdout.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/goal-session/stdout.expected.jsonl rename to examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/goal.snapshot.ts b/examples/acp-agent/tests/goal.snapshot.ts index 440eb66de6..42e3041721 100644 --- a/examples/acp-agent/tests/goal.snapshot.ts +++ b/examples/acp-agent/tests/goal.snapshot.ts @@ -14,7 +14,9 @@ import { foldGoal } from '@deepseek-ai/dsh-goal' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { describe, expect, it } from 'vitest' -const scenarioDir = join(dirname(fileURLToPath(import.meta.url)), 'snapshots/goal-session') +// This lifecycle proof has goal-specific timestamp normalization and semantic +// assertions, so it owns a separate snapshot root from the generic ACP suite. +const scenarioDir = join(dirname(fileURLToPath(import.meta.url)), 'goal-snapshots/goal-session') const fixtureFile = join(scenarioDir, 'session.jsonl') const overrideFile = join(scenarioDir, 'replay.override.json') const stdoutExpected = join(scenarioDir, 'stdout.expected.jsonl') diff --git a/knip.json b/knip.json index bc531313dc..8ba86243ca 100644 --- a/knip.json +++ b/knip.json @@ -72,7 +72,7 @@ "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/goal/goal-session": { - "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "entry": ["tests/**/*.spec.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/goal/tool-goal": { diff --git a/packages/goal/goal-session/package.json b/packages/goal/goal-session/package.json index c1493d017c..48a77019a3 100644 --- a/packages/goal/goal-session/package.json +++ b/packages/goal/goal-session/package.json @@ -34,7 +34,6 @@ "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 907dd906e0..4e2acb48e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -511,6 +511,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope @@ -1006,9 +1009,6 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - '@deepseek-ai/dsh-loader-smoke': - specifier: workspace:^ - version: link:../../support/loader-smoke '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope @@ -1036,9 +1036,6 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - '@deepseek-ai/dsh-loader-smoke': - specifier: workspace:^ - version: link:../../support/loader-smoke '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session From 78560dbc917ce906ca631f56a44eafbf2c9557f5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:52:21 +0800 Subject: [PATCH 18/25] test(commands): refresh ACP goal lifecycle snapshot --- .../tests/goal-snapshots/goal-session/stdout.expected.jsonl | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl index 893629bef6..61602dc289 100644 --- a/examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl +++ b/examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_goal_create","title":"Create goal","kind":"other","status":"in_progress","rawInput":"Finish the ACP goal-session snapshot proof"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_goal_create","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_goal_get","title":"Read current goal","kind":"read","status":"in_progress"}}} From 671ef4d7eec6fa253e0e6a15297d3dd23fe67507 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:49:29 +0800 Subject: [PATCH 19/25] fix(goal): synchronize workspace lock entries --- pnpm-lock.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f7a641df7..8e04aef04c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -425,6 +425,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -1034,9 +1037,6 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - '@deepseek-ai/dsh-loader-smoke': - specifier: workspace:^ - version: link:../../support/loader-smoke '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session From 73ad649c6aeb39cc768d2110f28e3811e69c4059 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:09:54 +0800 Subject: [PATCH 20/25] fix(goal): align session-stack lock importer --- pnpm-lock.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08f3251d26..2b4115f0a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -514,9 +514,6 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - '@deepseek-ai/dsh-loader-smoke': - specifier: workspace:^ - version: link:../../support/loader-smoke '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope @@ -1012,6 +1009,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope From df7be480e1c67d6ae65cdeecda34660da46fa5a9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:28:49 +0800 Subject: [PATCH 21/25] test(snapshot): cover invalid delayed prompt ordering --- packages/support/acp-snapshot/tests/harness.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 748b9e1606..1f69388968 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -458,6 +458,7 @@ describe('runScenario', () => { it.each([ [{ op: 'prompt', text: 'x' }, /prompt before newSession/], + [{ op: 'promptAndWaitForAgentMessage', text: 'x', waitForText: 'later' }, /promptAndWaitForAgentMessage before newSession/], [{ op: 'promptExpectError', text: 'x' }, /promptExpectError before newSession/], [{ op: 'promptAndCancel', text: 'x' }, /promptAndCancel before newSession/], [{ op: 'cancel' }, /cancel before newSession/], From 1dcfc63a789ae207a7bb61fd6ad860ab1da6fd6f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:26:26 +0800 Subject: [PATCH 22/25] docs(goal): refresh event source links --- docs/cordis-catalog/events.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index dfe0fdb502..92ccab90a8 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -33,7 +33,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:150`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:153`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -53,7 +53,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:159`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:162`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -75,7 +75,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:314`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:317`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial @@ -98,7 +98,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:267`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:270`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -121,7 +121,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:207`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:210`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -142,7 +142,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:217`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:220`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -163,7 +163,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:178`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:181`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -186,7 +186,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:229`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:232`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -211,7 +211,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens Types: [Agent](../core-data-structures/core.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:281`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:284`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -237,7 +237,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:244`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:247`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -259,7 +259,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:191`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:194`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -279,7 +279,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:168`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:171`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -301,7 +301,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:255`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:258`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -322,7 +322,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:291`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:294`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -343,7 +343,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:301`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:304`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` From 9ef673a5a3c2375680f64ae88ab611b188cedd73 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:47:24 +0800 Subject: [PATCH 23/25] test(commands): cover current ACP scenarios --- .../tests/snapshots/fs-escalation-approved/stdout.expected.jsonl | 1 + .../snapshots/subagent-depth-two-rejection/stdout.expected.jsonl | 1 + 2 files changed, 2 insertions(+) diff --git a/examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.expected.jsonl index b8b2f245ee..c42d0d5d56 100644 --- a/examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/stdout.expected.jsonl index 7f629a2d71..496f7568b8 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_root_child","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Start depth one","prompt":"Call subagent once. Ask that child to attempt one further subagent call, then report the result."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_root_child","status":"completed","content":[{"type":"content","content":{"type":"text","text":"DEPTH_ONE_DONE"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ROOT_DONE"}}}} From de72f972b71c58629fff7844986bbdedd971e860 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:58:01 +0800 Subject: [PATCH 24/25] Exercise normalized goal-round rate limits --- packages/goal/goal-session/tests/goal-session.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index 1d41514a3c..8495b73ecc 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -6,7 +6,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import GoalService, { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalView } from '@deepseek-ai/dsh-goal' -import { LlmAdapter } from '@deepseek-ai/dsh-llm' +import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { TurnEndReason } from '@deepseek-ai/dsh-session' @@ -232,7 +232,7 @@ describe('same-session goal driving', () => { }) it.each([ - ['rate limit', Object.assign(new Error('slow down'), { code: 'RATE_LIMIT' }), 'usage-limited'], + ['rate limit', new LlmError('slow down', 'RATE_LIMIT'), 'usage-limited'], ['request error', new Error('provider broke'), 'turn-error'], ['max tokens', maxTokensResponse('unfinished'), 'max-tokens'], ] as const)('stops after a %s without an automatic retry', async (_label, response, code) => { From 2aae64b5da49b62dcb101b0852ad52ed2aab241d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:35:49 +0800 Subject: [PATCH 25/25] fix(goal-session): preserve typed error variants --- packages/goal/goal-session/src/outcome.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/goal/goal-session/src/outcome.ts b/packages/goal/goal-session/src/outcome.ts index 1dc73cbe1a..e69f53eeec 100644 --- a/packages/goal/goal-session/src/outcome.ts +++ b/packages/goal/goal-session/src/outcome.ts @@ -28,8 +28,7 @@ export function classifyGoalRound(reason: TurnEndReason, durable: boolean): Goal case 'aborted': return { kind: 'pause', reason: reason.reason ?? 'cancelled' } case 'error': { - const code = reason.failure?.code ?? reason.code - const message = reason.failure?.message ?? reason.message ?? 'model request failed' + const { code, message } = reason.failure ?? reason return code === 'RATE_LIMIT' || code === 'QUOTA' ? { kind: 'blocked', code: 'usage-limited', message } : { kind: 'blocked', code: 'turn-error', message }