diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml new file mode 100644 index 0000000000..dc583d5ff1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.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 .agents/notes/implemented/feature/2026-07-28-feedback-command.md +2026-07-28-feedback-command.md: dad598a1830be445cdeb67d29acd8f6055bae139 +2026-07-28-feedback-command.zh.md: b9a6c62ef1c5ac134c09dd23e5628ed7b55d6ac2 diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.md b/.agents/notes/implemented/feature/2026-07-28-feedback-command.md new file mode 100644 index 0000000000..dad598a183 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.md @@ -0,0 +1,63 @@ +# Agent Note: `/feedback` command + +Status: implemented + +English | [中文](2026-07-28-feedback-command.zh.md) + +## Problem + +A user who notices something wrong mid-session has nowhere to put that observation. Telling the model wastes a turn, changes the conversation the user was having, and buries the remark in derived history where no later reader can find it. Writing it outside the session loses the context that makes it meaningful — which session, at which point, against which work. + +The capture surface has to be usable at the moment of annoyance, which rules out anything requiring the user to leave the interactive client, and it must not perturb the run in progress: no model tokens, no turn of work, no change to the request the user is waiting on. + +## Decision + +`@deepseek-ai/dsh-command-feedback` in `packages/feedback/command-feedback/` registers one global `feedback` command over `ctx.commands`. `/feedback ` acknowledges; bare or whitespace-only input returns a direct usage error. The handler is synchronous, injects only `commands`, and has no configuration. + +The package declares the log-only `feedback/record { text }` session event and exports `recordFeedback(session, text)` as its command-independent producer. The producer discards surrounding whitespace, rejects an empty result, and appends exactly one event. `/feedback` delegates to it, so another UI, hook, or host integration can record the same domain fact without constructing a slash command. + +`dsh-commands` still writes its `command/run` / `command/done` lifecycle pair around `/feedback`, but this command sets `recordInput: false`. Its `command/run` therefore carries the command identity and source without `args`; the feedback text exists only in `feedback/record`, while `command/done` carries the acknowledgement outcome. All three records are log-only and non-surface. Their appends start persistence's ordinary eager drain; nothing forces a flush, so acknowledgement reports that the feedback is in the log rather than already on disk. + +Capture is deliberately inert: nothing in this repository reads `feedback/record`. + +### Why feedback owns an event + +Feedback is a domain fact, while `/feedback` is one trigger. Keeping the only payload in `feedback/record` lets later triggers use the same event and lets consumers select feedback without depending on command names or parsing command lifecycle records. Omitting `command/run.args` for this definition avoids two authoritative-looking copies of one human remark. + +### Why the model never sees it + +Feedback is about the session, not input to it. Injecting it as a user message would change the next model request, contradicting the requirement that recording not perturb the run, and would make the remark part of the conversation it comments on. `command/run` and `command/done` are absent from `SurfaceEventType`, so they cannot acquire a `surfaceOp` or enter derived history even by mistake. + +### Verbatim text + +Surrounding whitespace is discarded, but nothing else is parsed. `/feedback /plan felt slow` records `/plan felt slow`; the leading `/plan` is content, not a nested command. Control-word grammar of the kind `/goal` uses would make the corresponding literal feedback impossible to express, which is the opposite of what a capture surface is for. + +### A new group + +`packages/feedback/` is a new group because no existing one owns this. `goal/` is objective state, `session-title/` is titles, `core/` is the product spine. The group holds one package; a consumer would join it rather than forcing this one to grow. + +## Alternatives considered + +**Use `command/run` as the feedback record.** Rejected because feedback would then be coupled to one trigger and consumers would have to identify a domain fact by command name. A non-command producer could not create the same record without pretending to execute a command. + +**Store the text in both `feedback/record` and `command/run.args`.** Rejected because one act would have two payload copies with no useful distinction. `recordInput: false` preserves the generic lifecycle while leaving the domain event authoritative. + +**Inject feedback as a user message via `agent.inject()`.** Needs no new event type and reuses the path `/goal` mutations take. Rejected: it makes the feedback model-visible, so it enters the next request, changes the run being commented on, and consumes tokens — contradicting all three parts of the no-perturbation requirement. + +**Make `/feedback` a true no-op that records nothing.** The most literal reading of "does not do anything". Rejected because it makes the command pointless: the stated requirement was that the remark reach the session log. + +**Register the command inside an existing package** such as `packages/ui/commands`. Avoids a new group and its README pair. Rejected: `ctx.commands` is the registry, not a home for arbitrary command implementations, and the requester asked for a standalone package. + +**Parse structure out of the text** (category prefixes, severity markers). Rejected as speculative: no consumer exists to use the structure, and any control-word grammar makes the corresponding literal feedback unrecordable. Verbatim text is the widest surface a future consumer can narrow; a parsed one cannot be widened after the fact. + +**Add a model-facing tool instead of a slash command.** Rejected: feedback is a direct human observation. Routing it through the model spends a turn, lets the model paraphrase the user's words, and makes the record contingent on the model choosing to call the tool. + +## Consequences + +The shipped `dsh` base mounts the command unconditionally — no configuration, no dependency on the goal stack. The Web client exposes it through its command adapter. Headless mode, ACP, and JSON-RPC do not provide a command adapter, so `/feedback` is unavailable there. + +The package owns one independent append-only event with no cross-event or mutable-data relation for an invariant companion to check. The event follows the session log's existing replay, fork, persistence, and crash-tail behavior. + +Deferred: no consumer; no structured fields; no amend or withdraw, since the log is append-only and this package adds no tombstone; and no explicit durability barrier, so an entry recorded immediately before a crash can be lost with any other unflushed tail. + +No keyless transcript snapshot accompanies this change, at the requester's explicit direction. Package tests, a real Loader composition test over a `cordis.yml`, and the shipped Web composition test cover registration, capture, model exclusion, and product assembly. diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md b/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md new file mode 100644 index 0000000000..b9a6c62ef1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md @@ -0,0 +1,63 @@ +# Agent Note: `/feedback` 命令 + +Status: implemented + +[English](2026-07-28-feedback-command.md) | 中文 + +## 问题 + +用户在会话中途发现问题时,没有地方记下这个观察。告诉模型会浪费一个轮次、改变用户原本进行的对话,并把这条评论埋进派生历史,使后续读者无法找到它。写到会话之外则会丢失让它有意义的上下文:属于哪个会话、处于哪个时点、针对哪项工作。 + +采集接口必须能在用户产生不满的那一刻使用,因此任何需要用户离开交互式客户端的方案都不可行;它还不能扰动正在进行的运行:不消耗模型 token、不产生工作轮次、不改变用户正在等待的请求。 + +## 决策 + +位于 `packages/feedback/command-feedback/` 的 `@deepseek-ai/dsh-command-feedback` 通过 `ctx.commands` 注册一个全局 `feedback` 命令。`/feedback ` 给出确认;空输入或仅含空白的输入返回直接用法错误。处理器是同步的,只注入 `commands`,且没有任何配置。 + +本包(package)声明仅写入日志的 `feedback/record { text }` 会话事件,并导出 `recordFeedback(session, text)`,作为不依赖命令的生产方。该生产方丢弃前后空白,拒绝空结果,并且恰好追加一个事件。`/feedback` 委托给它,因此其他 UI、钩子或 host 集成无需构造斜杠命令也能记录同一个领域事实。 + +`dsh-commands` 仍会围绕 `/feedback` 写入 `command/run` / `command/done` 生命周期配对,但该命令设置了 `recordInput: false`。因此,它的 `command/run` 携带命令标识与来源,但不携带 `args`;反馈文本只存在于 `feedback/record` 中,而 `command/done` 携带确认结果。三个记录都仅写入日志且非 surface。它们的追加会启动持久化的常规即时排空;没有任何环节强制 flush,因此确认文本报告的是反馈已进入日志,而非已经落盘。 + +采集刻意不产生后续动作:本仓库中没有任何代码读取 `feedback/record`。 + +### 为何反馈拥有自己的事件 + +反馈是领域事实,而 `/feedback` 是一种触发方式。只把载荷保存在 `feedback/record` 中,既让后续触发方式可以使用同一个事件,也让消费方无需依赖命令名或解析命令生命周期记录即可筛选反馈。在该定义中省略 `command/run.args`,可避免同一条人类评价出现两个看起来都具有权威性的副本。 + +### 为何模型永不看到它 + +反馈是关于会话的,而不是会话的输入。将其作为 user 消息注入会改变下一次模型请求,与「记录不得扰动运行」的要求相冲突,也会让该评论成为它所评论的那段对话的一部分。`command/run` 与 `command/done` 不属于 `SurfaceEventType`,因此即便出错也无法获得 `surfaceOp` 或进入派生历史。 + +### 原样文本 + +前后空白会被丢弃,但除此之外不做解析。`/feedback /plan felt slow` 记录 `/plan felt slow`;开头的 `/plan` 是内容,而非嵌套命令。若采用 `/goal` 那样的控制词语法,对应的字面反馈将无法表达,这与采集接口的目的正好相反。 + +### 一个新的分组 + +`packages/feedback/` 是新分组,因为现有分组都不拥有此职责:`goal/` 负责目标状态,`session-title/` 负责标题,`core/` 是产品主干。该分组目前只有一个包;未来的消费方应加入该分组,而不是迫使这个包不断膨胀。 + +## 考虑过的替代方案 + +**使用 `command/run` 作为反馈记录。** 已否决,因为这会将反馈与一种触发方式耦合,消费方还必须通过命令名识别领域事实。非命令生产方若不伪装成执行命令,就无法创建相同记录。 + +**同时在 `feedback/record` 与 `command/run.args` 中存储文本。** 已否决,因为同一行为会产生两个没有实质区别的载荷副本。`recordInput: false` 保留通用生命周期,同时让领域事件保持权威性。 + +**通过 `agent.inject()` 将反馈作为 user 消息注入。** 无需新增事件类型,并复用 `/goal` 变更所走的路径。已否决:它会让反馈对模型可见,从而进入下一次请求、改变正被评论的那次运行并消耗 token——与「不得扰动」要求的三个方面全部冲突。 + +**让 `/feedback` 成为真正的空操作,什么都不记录。** 这是对「什么都不做」最字面的理解。已否决:这会使命令失去意义——明确的要求是让这条评论进入会话日志。 + +**在现有包中注册该命令**,例如 `packages/ui/commands`。可省去新分组及其双语 README。已否决:`ctx.commands` 是注册表,而不是任意命令实现的归属地;且请求者明确要求独立的包。 + +**从文本中解析结构**(类别前缀、严重程度标记)。已否决,属于投机设计:目前没有消费方使用该结构,而任何控制词语法都会让对应的字面反馈无法记录。原样文本是未来消费方可以收窄的最宽接口;而已被解析的接口无法事后放宽。 + +**改为提供面向模型的工具。** 已否决:反馈是人类的直接观察。经由模型会消耗一个轮次、让模型改写用户的原话,并使记录取决于模型是否选择调用该工具。 + +## 后果 + +随附的 `dsh` 基础组合无条件挂载该命令:没有配置,也不依赖 goal 栈。Web 客户端通过命令适配器暴露该命令。无头模式、ACP 和 JSON-RPC 不提供命令适配器,因此 `/feedback` 在那里不可用。 + +本包拥有一个独立的仅追加事件,不存在跨事件关系或可变数据关系可供不变式伴生插件检查。该事件遵循会话日志现有的回放、fork、持久化和崩溃尾部行为。 + +延期事项:没有消费方;没有结构化字段;不支持修改或撤回,因为日志仅追加且本包不新增 tombstone;且没有显式持久化屏障,因此紧临崩溃前记录的条目可能与其他未 flush 的尾部一同丢失。 + +本次变更按请求者的明确指示不附带无密钥 transcript(文本记录)快照。包测试、基于真实 `cordis.yml` 的 Loader 组合测试,以及随附的 Web 组合测试覆盖注册、采集、模型排除和产品组装。 diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml index 1c070a03a2..7adc61691c 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.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 .agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md -2026-07-27-session-projection-and-command-log.md: 789e79f2ecab1a9f3ac717df86059150ed2d4da9 -2026-07-27-session-projection-and-command-log.zh.md: 4d680b37f5d49a243447542706c8b7ced8d80e2a +2026-07-27-session-projection-and-command-log.md: 8226325d4cd4047c3a9c41eb9427082c59753dd5 +2026-07-27-session-projection-and-command-log.zh.md: b55704f28799008b5e2323d846696e4768fcd6d6 diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md index 789e79f2ec..8226325d4c 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md @@ -119,11 +119,11 @@ The one existing violation of "no hooks through inject" — `DetailsInjected.use Two log-only (non-surface, model-invisible) events, mirroring the `tool/call`/`tool/result` pairing: ```ts ignore-check -'command/run': { commandId: string; name: string; args: string; source: CommandSource } +'command/run': { commandId: string; name: string; args?: string; source: CommandSource } 'command/done': { commandId: string; kind: 'success' | 'error'; text?: string } ``` -The host command executor (`packages/ui/commands`) appends `command/run` before invoking the handler and `command/done` at settlement — direct standalone appends on the receiving agent's session, in the same shape as every other plugin-owned log-only event after the [synthetic-turn removal](../../implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md): no turn wraps them (turns describe model-loop executions only), persistence drains them at ordinary checkpoints, and the commands package's own invariant companion enforces the run/done pairing. The payload is structured — `name` and `args` are the parser's own split (`parseCommand`'s name and rawInput), so a consumer (a projection unit folding its own command records, a rich command card) never re-parses a line. `text` is the handler's verbatim outcome — factual data of the same nature as `tool/result.content`, not presentation (how it is laid out remains client-computed at render time, satisfying the "presentation never enters the log" red line). Domains that want the model to know the outcome keep doing what they do today (plan's narration, goal's inject) — that is a domain decision, unchanged. +The host command executor (`packages/ui/commands`) appends `command/run` before invoking the handler and `command/done` at settlement — direct standalone appends on the receiving agent's session, in the same shape as every other plugin-owned log-only event after the [synthetic-turn removal](../../implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md): no turn wraps them (turns describe model-loop executions only), persistence drains them at ordinary checkpoints, and the commands package's own invariant companion enforces the run/done pairing. The payload is structured — `name` and, by default, `args` are the parser's own split (`parseCommand`'s name and rawInput), so a consumer (a projection unit folding its own command records, a rich command card) never re-parses a line. A definition sets `recordInput: false` when its authoritative domain event owns the payload; `command/run` then omits `args` rather than duplicating it. `text` is the handler's verbatim outcome — factual data of the same nature as `tool/result.content`, not presentation (how it is laid out remains client-computed at render time, satisfying the "presentation never enters the log" red line). Domains that want the model to know the outcome keep doing what they do today (plan's narration, goal's inject) — that is a domain decision, unchanged. Because committed events broadcast on the mux stream, refresh persistence, multi-tab sync, and fork/resume recovery all come for free. The `command.execute` RPC degrades to admission — `{ matched, commandId? }`: whether the line resolved, and the minted pairing id when it did, so the issuing client can correlate its request with the flow node the lifecycle events produce. The one-shot notice channel (`runDetached` → `noticeFor`) is retired. diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md index 4d680b37f5..b55704f287 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md @@ -58,7 +58,7 @@ declare module 'cordis' { ### 已交付的消费方:subagent 身份单元 -注册表的两处读面已经服务于本 RFC 协议计划之外的一个已交付消费方:[subagent 列表经投影单元读取身份](../../implemented/architecture/2026-08-06-subagent-list-identity-projection.md)注册了 `subagent` 单元——从 `subagent/descriptor` 以 last-wins 折叠出的持久 mode/label 身份——`SubagentService.listChildren` 对 live child 经 `snapshot()` 读取(水位缓存,零日志读),对 cold child 经一次持久化检查上的 `restore({}, events, 0)` 读取。注册表契约不变:没有失败通道、没有新读面——单元永不抛错,值缺席本身就是信号,缺席如何呈现是该消费方自己的决定。 +注册表的两处既有读法已经服务于本 RFC 协议计划之外的一个已交付消费方:[subagent 列表经投影单元读取身份](../../implemented/architecture/2026-08-06-subagent-list-identity-projection.md)注册了 `subagent` 单元——从 `subagent/descriptor` 按 last-wins 折叠出的持久化 mode/label 身份——`SubagentService.listChildren` 对 live child 经 `snapshot()` 读取(水位缓存,零日志读),对 cold child 则用一次持久化整读的结果调用 `restore({}, events, 0)` 读取。注册表契约不变:没有失败通道、没有新读法——单元永不抛错,值缺席本身就是信号,缺席如何呈现是该消费方自己的决定。 ### 协议层:历史尾页上的 projections 块 @@ -119,11 +119,11 @@ type UseProjection = { 两个仅日志(非 surface、模型不可见)事件,镜像 `tool/call`/`tool/result` 的配对: ```ts ignore-check -'command/run': { commandId: string; name: string; args: string; source: CommandSource } +'command/run': { commandId: string; name: string; args?: string; source: CommandSource } 'command/done': { commandId: string; kind: 'success' | 'error'; text?: string } ``` -host 侧命令执行器(`packages/ui/commands`)在调用处理器前追加 `command/run`,在结算时追加 `command/done`——在接收 agent 的会话上直接独立追加,与[合成轮次移除](../../implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md)之后所有插件自有 log-only 事件同一形状:没有轮次包裹它们(轮次只描述模型循环执行),持久化在常规检查点排空它们,run/done 配对由 commands 包自己的 invariant 伴生插件把守。载荷是结构化的——`name` 与 `args` 就是解析器自己的切分(`parseCommand` 的 name 与 rawInput),因此消费方(折叠自己命令记录的投影单元、富命令卡片)永远无需重新解析行文本。`text` 是处理器的原样结果——与 `tool/result.content` 同一性质的事实数据,不是呈现(版式如何编排仍由客户端在渲染时计算,满足「呈现永不入日志」这条红线)。想让模型知道结果的领域继续做它们今天在做的事(plan 的旁白、goal 的注入)——那是领域自己的决定,保持不变。 +host 侧命令执行器(`packages/ui/commands`)在调用处理器前追加 `command/run`,在结算时追加 `command/done`——在接收 agent 的会话上直接独立追加,与[合成轮次移除](../../implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md)之后所有插件自有 log-only 事件同一形状:没有轮次包裹它们(轮次只描述模型循环执行),持久化在常规检查点排空它们,run/done 配对由 commands 包自己的 invariant 伴生插件把守。载荷是结构化的——`name` 以及默认携带的 `args` 来自解析器自己的切分(`parseCommand` 的 name 与 rawInput),因此消费方(折叠自己命令记录的投影单元、富命令卡片)永远无需重新解析行文本。当载荷由权威领域事件持有时,命令定义会设置 `recordInput: false`;此时 `command/run` 省略 `args`,而不是重复该载荷。`text` 是处理器的原样结果——与 `tool/result.content` 同一性质的事实数据,不是呈现(版式如何编排仍由客户端在渲染时计算,满足「呈现永不入日志」这条红线)。想让模型知道结果的领域继续做它们今天在做的事(plan 的旁白、goal 的注入)——那是领域自己的决定,保持不变。 由于已提交事件会在 mux 流上广播,刷新后仍在、多标签页同步、fork/恢复后可还原这三件事随之全部自动获得。`command.execute` RPC 退化为准入判定——`{ matched, commandId? }`:该行是否匹配命中,以及命中时新铸的配对 id,发起命令的客户端据此把自己的请求与生命周期事件产出的 flow 节点关联起来。一次性通知通道(`runDetached` → `noticeFor`)就此下线。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 618f25c667..1da393bd06 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -78,6 +78,8 @@ flowchart LR cfg --> plugin_dsh_base_tool_skill plugin_dsh_base_commands["commands
@deepseek-ai/dsh-commands"] cfg --> plugin_dsh_base_commands + plugin_dsh_base_command_feedback["command-feedback
@deepseek-ai/dsh-command-feedback"] + cfg --> plugin_dsh_base_command_feedback plugin_dsh_base_goal["goal
@deepseek-ai/dsh-goal"] cfg --> plugin_dsh_base_goal plugin_dsh_base_goal_session["goal-session
@deepseek-ai/dsh-goal-session"] @@ -187,6 +189,7 @@ flowchart LR | `skill-local` | `@deepseek-ai/dsh-skill-local` | | `tool-skill` | `@deepseek-ai/dsh-tool-skill` | | `commands` | `@deepseek-ai/dsh-commands` | +| `command-feedback` | `@deepseek-ai/dsh-command-feedback` | | `goal` | `@deepseek-ai/dsh-goal` | | `goal-session` | `@deepseek-ai/dsh-goal-session` | | `command-goal` | `@deepseek-ai/dsh-command-goal` | diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 782279f074..447f14ae44 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -6,11 +6,13 @@ import { tmpdir } from 'node:os' import { afterEach, expect, it } from 'vitest' import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox' +import { SessionId } from '@deepseek-ai/dsh-session' // Empty type imports carry the tools/sandboxPolicy/approval Context merges. import type {} from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-sandbox-policy' import type {} from '@deepseek-ai/dsh-user-approval' import type {} from '@deepseek-ai/dsh-permission' +import type {} from '@deepseek-ai/dsh-commands' import { launchWebScaffold, type WebScaffold } from './scaffold.ts' /** @@ -79,4 +81,19 @@ it('assembles the shipped Web catalog with the confined access default', async ( expect(scaffold.ctx.sandboxPolicy.defaultMode).toBe('workspace-write') expect(scaffold.ctx.approval.config.policy).toBe('ask') expect(scaffold.ctx.permission.defaultPreset).toBe('workspace-write') + + const handle = await scaffold.ctx.agents.create({ + sessionId: SessionId('shipped-command-catalog'), + meta: { cwd: scaffold.workspaceCwd }, + agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }) + try { + expect(scaffold.ctx.commands.list(handle.agent)).toContainEqual({ + name: 'feedback', + description: 'record feedback about this session', + input: { hint: '' }, + }) + } finally { + await handle.dispose() + } }, 120_000) diff --git a/apps/web/tests/snapshots/lifecycle-chrome/command-menu.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/command-menu.expected.md index 1da9b9a45e..7b18ab188b 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/command-menu.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/command-menu.expected.md @@ -1,6 +1,7 @@ - listbox "Trigger suggestions": - text: Commands - option "compact Compact older conversation history" [selected] + - option "feedback record feedback about this session" - option "goal set or view the goal for a long-running task" - option "permission Switch the permission preset (sandbox mode + approval policy)" - option "plan Enter or leave plan mode" diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 20472ce0c9..3087f1ca39 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2522,6 +2522,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-trajectory` ([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts)) - `@deepseek-ai/dsh-client-ui-workspace` ([`packages/client/ui-workspace/src/index.ts`](../packages/client/ui-workspace/src/index.ts)) - `@deepseek-ai/dsh-command-compact` — requires `commands` · `compact` ([`packages/compact/command-compact/src/index.ts`](../packages/compact/command-compact/src/index.ts)) +- `@deepseek-ai/dsh-command-feedback` — requires `commands` ([`packages/feedback/command-feedback/src/index.ts`](../packages/feedback/command-feedback/src/index.ts)) - `@deepseek-ai/dsh-command-goal` — requires `commands` · `goals` ([`packages/goal/command-goal/src/index.ts`](../packages/goal/command-goal/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)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 0aeb9fa2fc..efff591552 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -343,7 +343,7 @@ A command was registered or unregistered. This is an unfiltered registry notific 'commands/change'(): void ``` -Source: [`packages/ui/commands/src/index.ts:154`](../../packages/ui/commands/src/index.ts) +Source: [`packages/ui/commands/src/index.ts:161`](../../packages/ui/commands/src/index.ts) ## `credentials/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index b5eeb95683..d70bb2bf32 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -451,7 +451,7 @@ async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise CommandResult | Promise } diff --git a/docs/core-data-structures/commands.zh.md b/docs/core-data-structures/commands.zh.md index 6339b3e87c..a2f82c06ac 100644 --- a/docs/core-data-structures/commands.zh.md +++ b/docs/core-data-structures/commands.zh.md @@ -31,6 +31,12 @@ interface CommandDefinition { readonly description: string /** Optional free-form input hint advertised to capable clients. */ readonly input?: CommandInputDescriptor + /** + * Whether `command/run` records `rawInput`. Defaults to true. A command + * whose domain event owns the payload sets this false to avoid duplicating + * that payload in the session log. + */ + readonly recordInput?: boolean /** Execute against the receiving agent without sending the command to the model. */ readonly handler: (invocation: CommandInvocation) => CommandResult | Promise } diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b506efc6be..182c2e71e7 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,7 +21,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:173`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`headless`](../packages/bundle/headless), [`jsonrpc`](../packages/ui/jsonrpc) | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:273`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | -| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy` | +| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:161`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy` | | `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/module-graph.md b/docs/module-graph.md index a50b658e2a..2d218e12a6 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -202,6 +202,9 @@ flowchart TD pkg_cli_demo["cli-demo"] pkg_jsonrpc_demo["jsonrpc-demo"] end + subgraph group_feedback["packages/feedback"] + pkg_command_feedback["command-feedback"] + end subgraph group_guard["packages/guard"] pkg_repeat_tool_guard["repeat-tool-guard"] end @@ -677,6 +680,9 @@ flowchart TD pkg_client_ui_conversation --> pkg_client_ui_slots pkg_client_ui_conversation --> pkg_invariants pkg_client_ui_conversation --> pkg_token_meter + pkg_command_feedback --> pkg_commands + pkg_command_feedback --> pkg_invariants + pkg_command_feedback --> pkg_session pkg_pty_local --> pkg_agent pkg_pty_local --> pkg_invariants pkg_pty_local --> pkg_pty @@ -1233,6 +1239,7 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/ui/user-approval) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | +| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 93b01139a8..3c037198da 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -205,7 +205,7 @@ Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/ 'command/done': { commandId: CommandId; kind: 'success' | 'error'; text?: string } ``` -Source: [`packages/ui/commands/src/index.ts:138`](../packages/ui/commands/src/index.ts) +Source: [`packages/ui/commands/src/index.ts:145`](../packages/ui/commands/src/index.ts) #### `command/run` — log-only @@ -217,12 +217,13 @@ Source: [`packages/ui/commands/src/index.ts:138`](../packages/ui/commands/src/in * and `args` are `parseCommand`'s own split (name and verbatim rawInput, * separator whitespace included), so a consumer (a projection unit * folding its own command records, a rich command card) never re-parses - * a line. + * a line. `args` is absent when the definition sets `recordInput: false` + * because an authoritative domain event owns the input payload. */ -'command/run': { commandId: CommandId; name: string; args: string; source: CommandSource } +'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource } ``` -Source: [`packages/ui/commands/src/index.ts:132`](../packages/ui/commands/src/index.ts) +Source: [`packages/ui/commands/src/index.ts:139`](../packages/ui/commands/src/index.ts) ### `compact/*` @@ -314,6 +315,20 @@ Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-st Source: [`packages/compact/compact/src/types.ts:29`](../packages/compact/compact/src/types.ts) +### `feedback/*` + +#### `feedback/record` — log-only + +```ts persistence-catalog +/** + * One recorded human remark about this session. Log-only and independent + * of its trigger; it never enters the model surface or derived history. + */ +'feedback/record': { text: string } +``` + +Source: [`packages/feedback/command-feedback/src/index.ts:24`](../packages/feedback/command-feedback/src/index.ts) + ### `goal/*` #### `goal/change` — log-only diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index e721814e79..d10f476f79 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.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 packages/README.md -README.md: dec4d71ca2d323fe05f918dd3bf4709cfa01878e -README.zh.md: 9596dfe8bf8d2d6144ffe7820886342707dd3009 +README.md: 8fbb6069a784a5bd45423a4e1ae11834a597750d +README.zh.md: 42a8d691344c716021188df6fd870a841d543f36 diff --git a/packages/README.md b/packages/README.md index dec4d71ca2..8fbb6069a7 100644 --- a/packages/README.md +++ b/packages/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Packages use the `@deepseek-ai/dsh-*` scope. Each is a Cordis `Service` subclass or function plugin; contributions use `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring rules: [package](AGENTS.md) and [root](../AGENTS.md#conventions). +Packages use the `@deepseek-ai/dsh-*` scope. Cordis `Service` subclasses and function plugins contribute through `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring rules: [package](AGENTS.md) and [root](../AGENTS.md#conventions). ## Hierarchy @@ -12,7 +12,8 @@ Packages live at `packages///`; groups are containers, while names r |---|---|---| | [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface | | [`typert/`](typert/README.md) | Type graph generation, artifact loading, and runtime registry | Product — stable surface | -| [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface | +| [`goal/`](goal/README.md) | Same-session goal persistence and lifecycle | Product — stable surface | +| [`feedback/`](feedback/README.md) | Human feedback | Product — stable surface | | [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface | | [`subprocess/`](subprocess/README.md) | Subprocess capability family: spawn seam + local process-tree implementation | Product — stable surface | | [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable surface | @@ -26,7 +27,7 @@ Packages live at `packages///`; groups are containers, while names r | [`context/`](context/README.md) | Model-visible request context, including workspace instructions and time context | Product — stable surface | | [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface | | [`tasks/`](tasks/README.md) | Generic background-task runtime and model-facing `task_*` control tools | Product — stable surface | -| [`workflow/`](workflow/README.md) | Workflow capability family: the script-engine seam, worker-thread engine, and model-facing `workflow` and fresh-agent `ralph` tools | Product — stable surface | +| [`workflow/`](workflow/README.md) | Script seam, worker-thread engine, and model-facing `workflow`/`ralph` tools | Product — stable surface | | [`web/`](web/README.md) | Web capability family: seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface | | [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | Product — stable surface | | [`todo/`](todo/README.md) | The model-facing `todo_write` tool | Product — stable surface | diff --git a/packages/README.zh.md b/packages/README.zh.md index 9596dfe8bf..42a8d69134 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -所有包都使用 `@deepseek-ai/dsh-*` scope。每个包都是 Cordis `Service` 子类或函数插件;所有贡献通过 `ctx.effect()`、`ctx.on()` 或 `ctx.waterfall()` 注册。编写规则见[包](AGENTS.md)与[根规则](../AGENTS.md#conventions)。 +所有包都使用 `@deepseek-ai/dsh-*` scope。Cordis `Service` 子类和函数插件的贡献通过 `ctx.effect()`、`ctx.on()` 或 `ctx.waterfall()` 注册。编写规则见[包](AGENTS.md)与[根规则](../AGENTS.md#conventions)。 ## 层级结构 @@ -12,7 +12,8 @@ |---|---|---| | [`core/`](core/README.md) | 产品 API 主干:会话、提示词、工具、agent(智能体)服务与具体循环 | 产品:稳定表面 | | [`typert/`](typert/README.md) | 类型图生成、产物加载与运行时注册表 | 产品:稳定表面 | -| [`goal/`](goal/README.md) | 持久化的同会话 goal 状态与生命周期 | 产品:稳定表面 | +| [`goal/`](goal/README.md) | 同会话 goal 的持久化与生命周期 | 产品:稳定表面 | +| [`feedback/`](feedback/README.md) | 人类反馈 | 产品:稳定表面 | | [`llm/`](llm/README.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定表面 | | [`subprocess/`](subprocess/README.md) | 进程管理能力系列:spawn seam + 本地进程树实现 | 产品:稳定表面 | | [`bash/`](bash/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定表面 | @@ -26,7 +27,7 @@ | [`context/`](context/README.md) | 模型可见请求上下文,包括 workspace 指令和时间上下文 | 产品:稳定表面 | | [`subagent/`](subagent/README.md) | Subagent 能力系列:提供方注册表 seam 和面向模型的委托工具 | 产品:稳定表面 | | [`tasks/`](tasks/README.md) | 通用后台任务运行时和面向模型的 `task_*` 控制工具 | 产品:稳定表面 | -| [`workflow/`](workflow/README.md) | 工作流能力系列:脚本引擎 seam、worker 线程引擎、面向模型的 `workflow` 与新 agent `ralph` 工具 | 产品:稳定表面 | +| [`workflow/`](workflow/README.md) | 脚本 seam、worker 线程引擎和面向模型的 `workflow`/`ralph` 工具 | 产品:稳定表面 | | [`web/`](web/README.md) | Web 能力系列:seam、搜索/获取提供方实现和面向模型的 Web 工具 | 产品:稳定表面 | | [`spill/`](spill/README.md) | 溢出能力系列:存储 seam、本地实现、工具结果溢出策略 | 产品:稳定表面 | | [`todo/`](todo/README.md) | 面向模型的 `todo_write` 工具 | 产品:稳定表面 | diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index e284bdca7b..f0577552cc 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -217,6 +217,9 @@ - id: commands name: '@deepseek-ai/dsh-commands' + - id: command-feedback + name: '@deepseek-ai/dsh-command-feedback' + - id: goal name: '@deepseek-ai/dsh-goal' diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 14399cd441..c6519171ca 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -39,6 +39,7 @@ "@deepseek-ai/dsh-bash-env": "workspace:^", "@deepseek-ai/dsh-bash-sandbox": "workspace:^", "@deepseek-ai/dsh-command-compact": "workspace:^", + "@deepseek-ai/dsh-command-feedback": "workspace:^", "@deepseek-ai/dsh-command-goal": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-compact-basic": "workspace:^", diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index dc2f8c5967..39267c5d03 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -689,9 +689,9 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi /** * Fixture parallel of the plan unit's double-event fold: `command/run` - * records named `plan` set the wanted target (`off` → false, else true); - * `plan/mode` commits and clears it. `wanted` is exposed for the prompt - * boundary (the fixture's step/start parallel). + * records named `plan` with recorded input set the wanted target (`off` → + * false, else true); `plan/mode` commits and clears it. `wanted` is exposed + * for the prompt boundary (the fixture's step/start parallel). */ function foldPlan(log: readonly SessionEvent[]): { active: boolean; pending: boolean; wanted: boolean | null } { let active = false @@ -700,7 +700,8 @@ function foldPlan(log: readonly SessionEvent[]): { active: boolean; pending: boo const item = event as unknown as { type: string; data?: Record } if (item.type === 'command/run' && item.data?.['name'] === 'plan') { const args = item.data['args'] - wanted = (typeof args === 'string' ? args : '').trim() !== 'off' + if (typeof args !== 'string') continue + wanted = args.trim() !== 'off' } else if (item.type === 'plan/mode') { active = item.data?.['active'] === true wanted = null @@ -1007,9 +1008,11 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: seq: event.seq, }] } - // The plan unit advances on its two folded event kinds. + // The plan unit advances on its two folded event kinds when the command + // lifecycle contains the input that represents a plan selection. + const commandData = event as unknown as { data: { name?: string; args?: unknown } } if (type === 'plan/mode' || (type === 'command/run' - && (event as unknown as { data: { name?: string } }).data.name === 'plan')) { + && commandData.data.name === 'plan' && typeof commandData.data.args === 'string')) { return [{ type: 'session/projection', sessionId: id, diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index d24b963d6b..fb2c281331 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -230,7 +230,10 @@ export interface CommandNode { commandId: CommandId /** Command name (run payload's structured field); null when the run fell outside the window. */ name: string | null - /** Verbatim rawInput after the name, separator whitespace included (run payload); null when the run fell outside the window. */ + /** + * Verbatim rawInput after the name, including separator whitespace; null + * when omitted by the command or when the run fell outside the window. + */ args: string | null /** Settlement outcome (done payload); null while the command is still executing. */ outcome: { kind: 'success' | 'error'; text?: string } | null diff --git a/packages/client/runtime/src/client/sessions/transcript-adapter.ts b/packages/client/runtime/src/client/sessions/transcript-adapter.ts index 306571b2bf..d970be596b 100644 --- a/packages/client/runtime/src/client/sessions/transcript-adapter.ts +++ b/packages/client/runtime/src/client/sessions/transcript-adapter.ts @@ -313,10 +313,10 @@ export class TranscriptAdapter { // enter the client program, so this wire consumer narrows structurally // (the same posture as tool/code-dispatch in session.ts). if ((event.type as string) === 'command/run') { - const data = event.data as unknown as { commandId: CommandId; name: string; args: string } + const data = event.data as unknown as { commandId: CommandId; name: string; args?: string } this.commandIdx.set(data.commandId, { kind: 'command', seq: event.seq, time: event.time, - commandId: data.commandId, name: data.name, args: data.args, outcome: null, + commandId: data.commandId, name: data.name, args: data.args ?? null, outcome: null, }) return true } diff --git a/packages/client/runtime/tests/event-script.ts b/packages/client/runtime/tests/event-script.ts index ebd64dc60f..51e982e8f4 100644 --- a/packages/client/runtime/tests/event-script.ts +++ b/packages/client/runtime/tests/event-script.ts @@ -90,6 +90,8 @@ export const ev = { } }), commandRun: (seq: number, commandId: string, name: string, args = ''): SessionEvent => at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }), + commandRunWithoutInput: (seq: number, commandId: string, name: string): SessionEvent => + at(seq, { type: 'command/run', data: { commandId, name, source: { kind: 'user' } } }), commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent => at(seq, { type: 'command/done', data: { commandId, kind, ...text === undefined ? {} : { text } } }), /** A compaction's log-only `compact/summary` provenance record. */ diff --git a/packages/client/runtime/tests/transcript-adapter.spec.ts b/packages/client/runtime/tests/transcript-adapter.spec.ts index 031acf1780..e4ef3b0e1a 100644 --- a/packages/client/runtime/tests/transcript-adapter.spec.ts +++ b/packages/client/runtime/tests/transcript-adapter.spec.ts @@ -432,6 +432,14 @@ describe('TranscriptAdapter', () => { expect(adapter.nodes()[0]).toMatchObject({ kind: 'command', name: 'goal', args: ' ship it', outcome: null }) }) + it('represents command input omitted by the host as null', () => { + const adapter = new TranscriptAdapter() + adapter.reset([ev.commandRunWithoutInput(0, 'cmd-private', 'feedback')]) + expect(adapter.nodes()[0]).toMatchObject({ + kind: 'command', name: 'feedback', args: null, outcome: null, + }) + }) + it('soft-falls a done-only window into a node built from the done (cross-window cut)', () => { const adapter = new TranscriptAdapter() adapter.reset([ev.commandDone(80, 'cmd-3', 'error', '失败了')]) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 63c1a491e1..d773b1c10d 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1755,7 +1755,7 @@ 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 handler: (invocation: CommandInvocation) => CommandResult | Promise;\n}', + declaration: 'export interface CommandDefinition {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly recordInput?: boolean;\n readonly handler: (invocation: CommandInvocation) => CommandResult | Promise;\n}', }, { name: 'CommandDescriptor', diff --git a/packages/feedback/README.i18n.yaml b/packages/feedback/README.i18n.yaml new file mode 100644 index 0000000000..31ed2d25e8 --- /dev/null +++ b/packages/feedback/README.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 packages/feedback/README.md +README.md: 7962a16ee9bc7d8a969a466591d761829cd55d7f +README.zh.md: aad8f4d797ff16a5ef9be4c968fb28d708bad13e diff --git a/packages/feedback/README.md b/packages/feedback/README.md new file mode 100644 index 0000000000..7962a16ee9 --- /dev/null +++ b/packages/feedback/README.md @@ -0,0 +1,11 @@ +# feedback/ — recorded human feedback + +English | [中文](README.zh.md) + +The feedback family lets a human record a remark about the session without acting on it. Feedback is durable session-log content, separate from the model conversation and from any policy that might later read it. + +| Package | Role | ctx key | +|---|---|---| +| `command-feedback/` | Trigger-independent `feedback/record` event plus the human-facing `/feedback` producer | — | + +A recorded remark is log-only: it never enters the model surface or derived history, and no shipped plugin consumes it. A future consumer reads `feedback/record` events from the session log rather than changing how they are captured. diff --git a/packages/feedback/README.zh.md b/packages/feedback/README.zh.md new file mode 100644 index 0000000000..aad8f4d797 --- /dev/null +++ b/packages/feedback/README.zh.md @@ -0,0 +1,11 @@ +# feedback/:记录的人类反馈 + +[English](README.md) | 中文 + +feedback 家族让人类记录对会话的评价,但不据此采取任何动作。反馈属于持久的会话日志内容,与模型对话以及后续可能读取它的任何策略相互独立。 + +| 包 | 职责 | ctx 键 | +|---|---|---| +| `command-feedback/` | 与触发方式无关的 `feedback/record` 事件,以及面向用户的 `/feedback` 生产方 | 无 | + +被记录的评价仅写入日志:它绝不会进入模型 surface 或派生历史,随附插件也不会消费它。未来的消费方从会话日志中读取 `feedback/record` 事件,而不是改变它们的采集方式。 diff --git a/packages/feedback/command-feedback/README.i18n.yaml b/packages/feedback/command-feedback/README.i18n.yaml new file mode 100644 index 0000000000..39036b95fe --- /dev/null +++ b/packages/feedback/command-feedback/README.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 packages/feedback/command-feedback/README.md +README.md: fb7e851774e4d3008cb28f2e70aa4fbe7d26a38a +README.zh.md: 5d6750040cba065412b51ad6a49d6bd79502e98e diff --git a/packages/feedback/command-feedback/README.md b/packages/feedback/command-feedback/README.md new file mode 100644 index 0000000000..fb7e851774 --- /dev/null +++ b/packages/feedback/command-feedback/README.md @@ -0,0 +1,59 @@ +# @deepseek-ai/dsh-command-feedback + +English | [中文](README.zh.md) + +Trigger-independent session feedback plus human-facing `/feedback` capture. The package exports `recordFeedback(session, text)`, which appends one log-only `feedback/record` event. Its plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md), so every composed command adapter discovers it; the shipped Web client executes it without a model turn. + +## Command contract + +| Input | Result | +|---|---| +| `/feedback ` | Append `feedback/record` and acknowledge with `Feedback recorded.` | +| `/feedback` | Return a direct usage error. Whitespace-only input is treated as empty. | + +Surrounding whitespace is discarded, but feedback is otherwise unparsed: no truncation, case folding, or control words. Text that looks like another command, such as `/feedback /plan felt slow`, is feedback content. Repeated commands each produce their own event; nothing is replaced or merged. + +## What this plugin does and does not do + +`recordFeedback(session, text)` is the command-independent write path. It rejects empty normalized text and appends `feedback/record { text }`; a different UI, hook, or host integration can call it without constructing a slash command. The `/feedback` handler uses that producer, starts no model work, and no plugin in this repository reads the event. + +The feedback text appears in exactly one durable payload: `feedback/record`. [`dsh-commands`](../../ui/commands/README.md) still appends its generic `command/run` / `command/done` pairing, but this definition sets `recordInput: false`, so `command/run` omits `args`; the paired `command/done` carries only the outcome. All three events are log-only and absent from the ordered surface, `deriveMessages()`, and model requests. These appends start persistence's ordinary eager drain, but neither producer forces `session/flush`, so acknowledgement means the feedback is in the log, not that it has reached disk. Rejected empty input leaves only the command pairing settled as `kind: 'error'`, with no `feedback/record`. + +The event is authoritative rather than the command record because feedback may arrive through a trigger other than `/feedback`. Keeping the payload out of `command/run` avoids two records carrying the same text. + +## Composition + +The producer injects only `commands`. A custom app mounts the registry plus this plugin: + +```yaml +- id: commands + name: '@deepseek-ai/dsh-commands' +- id: command-feedback + name: '@deepseek-ai/dsh-command-feedback' +``` + +The shipped `dsh` base mounts this command unconditionally; it has no configuration and no dependency on the persisted-goal stack. The Web client exposes it through the command adapter. Headless mode, ACP automation, and JSON-RPC do not provide a command adapter, so they do not expose it. + +## Model Experience + +### Human `/feedback` capture + +#### What the model sees + +Nothing. The slash input, `feedback/record`, and the acknowledgement are absent from model requests. The feedback event and registry lifecycle records are log-only and carry no `surfaceOp`, so they never reach the ordered surface, `deriveMessages()`, or a system prompt. Recording feedback during a turn does not change that turn's remaining requests. + +#### Token effect + +Zero direct token effect. Neither an accepted entry nor a usage error adds model tokens, in the recording turn or any later one. + +#### KV Cache effect + +Independent of the model request path. Recording appends to the session log only, leaving an already-reusable request prefix untouched. Nothing this package contributes can invalidate cache reuse. + +## Known Limitations and Deferred Work + +- **Nothing consumes the recorded feedback** — capture is deliberately inert. There is no retrieval, aggregation, export, or reporting surface, and no model-facing tool reads `feedback/record`; a consumer is a separate package. +- **No structured fields** — an entry is one free-text string with no category, severity, or referenced-event link, so feedback cannot be filtered by subject without re-reading its text. +- **No amend or withdraw** — the session log is append-only and this package adds no tombstone, so a mistaken entry stays recorded and can only be superseded by a later one. +- **No explicit durability barrier** — the acknowledgement follows the append, not a flush, so an entry recorded immediately before a crash can be lost with any other unflushed tail. Feedback is not worth forcing a synchronous disk write for; a consumer that needs one awaits `ctx.sessions.flush(session)`. +- **Web only in the shipped front doors** — headless mode, ACP automation, and JSON-RPC do not provide a command adapter, so `/feedback` is unavailable there. diff --git a/packages/feedback/command-feedback/README.zh.md b/packages/feedback/command-feedback/README.zh.md new file mode 100644 index 0000000000..5d6750040c --- /dev/null +++ b/packages/feedback/command-feedback/README.zh.md @@ -0,0 +1,59 @@ +# @deepseek-ai/dsh-command-feedback + +[English](README.md) | 中文 + +与触发方式无关的会话反馈,以及面向用户的 `/feedback` 采集。本包导出 `recordFeedback(session, text)`,后者追加一个仅写入日志的 `feedback/record` 事件。该插件通过 [`ctx.commands`](../../ui/commands/README.md) 注册一个全局命令,因此每个已组合的命令适配器都能发现它;随附的 Web 客户端无需模型轮次即可执行。 + +## 命令契约 + +| 输入 | 结果 | +|---|---| +| `/feedback ` | 追加 `feedback/record`,并以 `Feedback recorded.` 确认。 | +| `/feedback` | 返回一个直接用法错误。仅含空白的输入视为空输入。 | + +前后空白会被丢弃,但除此之外,反馈内容不会被解析:没有截断、大小写折叠或控制词。看起来像另一个命令的文本(例如 `/feedback /plan felt slow`)就是反馈内容。重复执行命令时,每次都会产生一个事件;不会发生替换或合并。 + +## 本插件做什么、不做什么 + +`recordFeedback(session, text)` 是不依赖命令的写入路径。它拒绝规范化后为空的文本,并追加 `feedback/record { text }`;其他 UI、钩子或 host 集成无需构造斜杠命令即可调用它。`/feedback` 处理器通过该生产方写入,不启动任何模型工作;本仓库中也没有任何插件读取该事件。 + +反馈文本只出现在一个持久载荷中:`feedback/record`。[`dsh-commands`](../../ui/commands/README.md) 仍会追加通用的 `command/run` / `command/done` 配对,但此定义设置了 `recordInput: false`,因此 `command/run` 会省略 `args`;配对的 `command/done` 只携带结果。三个事件都仅写入日志,不出现在有序 surface、`deriveMessages()` 以及模型请求中。这些追加会启动持久化的常规即时排空,但两个生产方都不会强制 `session/flush`,因此确认文本表示反馈已进入日志,而不表示它已经落盘。被拒绝的空输入只会留下以 `kind: 'error'` 结算的命令配对,不会产生 `feedback/record`。 + +权威记录是该事件,而不是命令记录,因为反馈可能来自 `/feedback` 之外的触发方式。让载荷不进入 `command/run`,可避免两条记录携带相同文本。 + +## 组合 + +生产方只注入 `commands`。自定义应用挂载注册表以及本插件: + +```yaml +- id: commands + name: '@deepseek-ai/dsh-commands' +- id: command-feedback + name: '@deepseek-ai/dsh-command-feedback' +``` + +随附的 `dsh` 基础组合无条件挂载此命令;它没有配置,也不依赖持久 goal 栈。Web 客户端通过命令适配器暴露该命令。无头模式、ACP 自动化和 JSON-RPC 不提供命令适配器,因此不会暴露它。 + +## 模型体验 + +### 用户 `/feedback` 采集 + +#### 模型看到的内容 + +无。斜杠输入、`feedback/record` 以及确认文本都不出现在模型请求中。反馈事件和注册表生命周期记录仅写入日志且不携带 `surfaceOp`,因此它们绝不会进入有序 surface、`deriveMessages()` 或系统提示词。在某个轮次中记录反馈不会改变该轮次剩余的请求。 + +#### Token 影响 + +无直接 token 影响。无论是已接受的条目还是用法错误,都不会在记录所在轮次或此后任何轮次增加模型 token。 + +#### KV Cache 影响 + +与模型请求路径无关。记录只追加到会话日志,不触碰已经可复用的请求前缀。本包贡献的任何内容都不会使缓存复用失效。 + +## 已知限制与暂缓工作 + +- **没有任何消费方读取被记录的反馈**:采集刻意不产生任何后续动作。这里没有检索、聚合、导出或报告 surface,也没有面向模型的工具读取 `feedback/record`;消费方是另一个独立包。 +- **没有结构化字段**:一条条目就是一个自由文本字符串,没有类别、严重程度或关联事件链接,因此无法在不重读文本的情况下按主题过滤反馈。 +- **不支持修改或撤回**:会话日志是仅追加的,本包也不新增 tombstone,因此错误的条目会一直保留在记录中,只能由后续条目取代。 +- **没有显式持久化屏障**:确认文本紧随追加而非 flush,因此紧临崩溃前记录的条目可能与其他未 flush 的尾部一同丢失。为反馈强制同步写盘并不值得;需要该保证的消费方可自行等待 `ctx.sessions.flush(session)`。 +- **随附的产品入口中只有 Web 使用此命令**:无头模式、ACP 自动化和 JSON-RPC 不提供命令适配器,因此 `/feedback` 在那里不可用。 diff --git a/packages/feedback/command-feedback/package.json b/packages/feedback/command-feedback/package.json new file mode 100644 index 0000000000..535c438a63 --- /dev/null +++ b/packages/feedback/command-feedback/package.json @@ -0,0 +1,43 @@ +{ + "name": "@deepseek-ai/dsh-command-feedback", + "description": "Log-only session feedback producer and human-facing slash command", + "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" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-commands": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@cordisjs/plugin-include": "workspace:^", + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/feedback/command-feedback/src/index.ts b/packages/feedback/command-feedback/src/index.ts new file mode 100644 index 0000000000..ae78b3cd4f --- /dev/null +++ b/packages/feedback/command-feedback/src/index.ts @@ -0,0 +1,63 @@ +/** + * Session feedback event plus the human-facing `/feedback` producer. Recording + * appends one authoritative log-only event and does not start model work. The + * append is eager but unflushed, so acknowledgement reports that the entry is + * logged, not that it reached disk. + * @module @deepseek-ai/dsh-command-feedback + */ + +import type { Context } from 'cordis' +import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands' +import type { Session } from '@deepseek-ai/dsh-session' + +export const name = 'command-feedback' +export const inject = ['commands'] + +const USAGE = 'Usage: /feedback ' + +declare module '@deepseek-ai/dsh-session' { + interface SessionEventMap { + /** + * One recorded human remark about this session. Log-only and independent + * of its trigger; it never enters the model surface or derived history. + */ + 'feedback/record': { text: string } + } +} + +/** + * Record feedback independently of any UI trigger. + * @param session - session the feedback describes. + * @param text - human-authored feedback; surrounding whitespace is discarded. + * @throws {TypeError} when the normalized text is empty. + */ +export function recordFeedback(session: Session, text: string): void { + const normalized = text.trim() + if (normalized.length === 0) throw new TypeError('feedback text must not be empty') + session.append('feedback/record', { text: normalized }) +} + +/** + * Validate, record, and acknowledge one feedback entry. Returning an error + * leaves no `feedback/record` event. + * @param invocation - receiving agent, raw command input, and UI cancellation. + * @returns an acknowledgement, or a usage error when no feedback text was supplied. + */ +function executeFeedbackCommand(invocation: CommandInvocation): CommandResult { + if (invocation.rawInput.trim().length === 0) { + return { kind: 'error', text: `Feedback text is required. ${USAGE}` } + } + recordFeedback(invocation.agent.session, invocation.rawInput) + return { kind: 'success', text: 'Feedback recorded.' } +} + +/** Register the global `/feedback` command for every composed command adapter. */ +export function apply(ctx: Context): void { + ctx.commands.register({ + name: 'feedback', + description: 'record feedback about this session', + input: { hint: '' }, + recordInput: false, + handler: executeFeedbackCommand, + }) +} diff --git a/packages/feedback/command-feedback/src/invariant.ts b/packages/feedback/command-feedback/src/invariant.ts new file mode 100644 index 0000000000..9c825a6e87 --- /dev/null +++ b/packages/feedback/command-feedback/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-command-feedback`. + * @module @deepseek-ai/dsh-command-feedback/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-command-feedback' + +/** Cordis companion plugin name. */ +export const name = 'command-feedback-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: each `feedback/record` is an independent append-only + * fact with no cross-event or mutable-data relationship. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts new file mode 100644 index 0000000000..145b7ccf11 --- /dev/null +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' +import CommandService from '@deepseek-ai/dsh-commands' +import SessionStore, { foldSurface, Session, SessionId } from '@deepseek-ai/dsh-session' +import * as commandFeedback from '@deepseek-ai/dsh-command-feedback' + +interface Harness { + readonly ctx: Context + readonly agent: Agent + readonly session: Session + readonly plugin: Awaited> +} + +/** Build a live idle agent over a store-owned session, as an app's spine does. */ +function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } { + const session = ctx.sessions.create(SessionId(id)) + const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) + let status: AgentStatus = 'idle' + const agent: Agent = { + id: session.id, + options: {}, + session, + inbox, + ctx: new Context(), + get status() { return status }, + send: () => {}, + followup: () => {}, + steer: () => {}, + inject: () => {}, + cancel() { status = 'idle' }, + runMaintenance: task => task(new AbortController().signal), + whenIdle() { return Promise.resolve() }, + } + return { agent, session } +} + +/** Mount the real command registry and this producer. */ +async function harness(): Promise { + const ctx = new Context() + await ctx.plugin(CommandService) + await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionStore) + const plugin = await ctx.plugin(commandFeedback) + const { agent, session } = stubAgent(ctx, `command-feedback-${Math.random()}`) + ctx.agents.register(agent) + return { ctx, agent, session, plugin } +} + +/** Execute `/feedback` through the same registry boundary as a UI adapter. */ +async function run(test: Harness, suffix = ''): Promise<{ kind: string; text?: string }> { + const settled = await test.ctx.commands.execute( + test.agent, + `/feedback${suffix}`, + new AbortController().signal, + ) + if (settled === undefined) throw new Error('feedback command was not registered') + return settled.result +} + +/** Authoritative feedback payloads in log order. */ +function feedbackTexts(session: Session): string[] { + return session.events + .filter(event => event.type === 'feedback/record') + .map(event => event.data.text) +} + +describe('@deepseek-ai/dsh-command-feedback registration', () => { + it('registers one global command with Loader-safe exports and disposes it', async () => { + const test = await harness() + expect(commandFeedback.name).toBe('command-feedback') + expect(commandFeedback.inject).toEqual(['commands']) + expect('default' in commandFeedback).toBe(false) + const loader = Object.create(Loader.prototype) as Loader + expect(loader.unwrapExports(commandFeedback)).toBe(commandFeedback) + + expect(test.ctx.commands.list(test.agent)).toContainEqual({ + name: 'feedback', + description: 'record feedback about this session', + input: { hint: '' }, + }) + expect(test.ctx.commands.find(test.agent, 'feedback')).toMatchObject({ recordInput: false }) + + await test.plugin.dispose() + expect(test.ctx.commands.find(test.agent, 'feedback')).toBeUndefined() + }) +}) + +describe('/feedback human command', () => { + it('acknowledges feedback and records its payload exactly once in the domain event', async () => { + const test = await harness() + await expect(run(test, ' the diff view is unreadable')).resolves.toEqual({ + kind: 'success', + text: 'Feedback recorded.', + }) + expect(feedbackTexts(test.session)).toEqual(['the diff view is unreadable']) + const commandRun = test.session.events.find(event => event.type === 'command/run') + expect(commandRun?.type === 'command/run' && Object.hasOwn(commandRun.data, 'args')).toBe(false) + expect(JSON.stringify(test.session.events).match(/the diff view is unreadable/gu)).toHaveLength(1) + }) + + it('exports a command-independent feedback producer', async () => { + const test = await harness() + commandFeedback.recordFeedback(test.session, ' recorded outside a command ') + expect(test.session.events.map(event => event.type)).toEqual(['feedback/record']) + expect(feedbackTexts(test.session)).toEqual(['recorded outside a command']) + expect(() => { commandFeedback.recordFeedback(test.session, ' \n\t ') }) + .toThrow('feedback text must not be empty') + expect(feedbackTexts(test.session)).toEqual(['recorded outside a command']) + }) + + it('keeps command bookkeeping around the authoritative feedback event', async () => { + const test = await harness() + await run(test, ' nothing else happens') + expect(test.session.events.map(event => event.type)).toEqual([ + 'command/run', 'feedback/record', 'command/done', + ]) + }) + + it('normalizes surrounding whitespace without parsing command-like content', async () => { + const test = await harness() + await run(test, ' /plan felt SLOW\n\ttwice today ') + expect(feedbackTexts(test.session)).toEqual(['/plan felt SLOW\n\ttwice today']) + }) + + it('records each entry separately without replacing earlier ones', async () => { + const test = await harness() + await run(test, ' first') + await run(test, ' second') + expect(feedbackTexts(test.session)).toEqual(['first', 'second']) + }) + + it('records concurrent submissions in dispatch order', async () => { + const test = await harness() + const signal = new AbortController().signal + // Command adapters may dispatch concurrent requests without awaiting one another. + const settled = await Promise.all([ + test.ctx.commands.execute(test.agent, '/feedback first', signal), + test.ctx.commands.execute(test.agent, '/feedback second', signal), + ]) + expect(settled.map(item => item?.result)).toEqual([ + { kind: 'success', text: 'Feedback recorded.' }, + { kind: 'success', text: 'Feedback recorded.' }, + ]) + expect(feedbackTexts(test.session)).toEqual(['first', 'second']) + }) + + it('keeps every recorded event off the model surface and out of derived history', async () => { + const test = await harness() + await run(test, ' invisible to the model') + for (const event of test.session.events) { + expect('surfaceOp' in event).toBe(false) + expect(test.session.deriveEventMessage(event)).toBeNull() + } + expect(foldSurface(test.session.events).nodes).toEqual([]) + expect(test.session.surface.nodes).toEqual([]) + expect(test.session.deriveMessages()).toEqual([]) + }) + + it('rejects empty and whitespace-only input as a failed command record', async () => { + const test = await harness() + const expected = { + kind: 'error', + text: 'Feedback text is required. Usage: /feedback ', + } + await expect(run(test)).resolves.toEqual(expected) + await expect(run(test, ' \n\t ')).resolves.toEqual(expected) + expect(feedbackTexts(test.session)).toEqual([]) + const done = test.session.events.filter(event => event.type === 'command/done') + expect(done.map(event => event.data.kind)).toEqual(['error', 'error']) + for (const event of test.session.events) { + if (event.type === 'command/run') expect(Object.hasOwn(event.data, 'args')).toBe(false) + } + }) + + it('records nothing when dispatch rejects an already-cancelled request', async () => { + const test = await harness() + const controller = new AbortController() + controller.abort(new Error('user cancelled the command')) + await expect(test.ctx.commands.execute(test.agent, '/feedback too late', controller.signal)) + .rejects.toThrow('user cancelled the command') + expect(test.session.events).toEqual([]) + }) +}) diff --git a/packages/feedback/command-feedback/tests/loader-composition.spec.ts b/packages/feedback/command-feedback/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..227672d167 --- /dev/null +++ b/packages/feedback/command-feedback/tests/loader-composition.spec.ts @@ -0,0 +1,110 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' +import CommandService from '@deepseek-ai/dsh-commands' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import * as CommandFeedback from '@deepseek-ai/dsh-command-feedback' + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +/** Register one idle agent over a store-owned session, as an app's spine does. */ +function agent(ctx: Context): Agent { + const scope = ctx.plugin(() => {}) + const id = SessionId('feedback-loader-agent') + const session = ctx.sessions.create(id) + const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) + let status: AgentStatus = 'idle' + const value: Agent = { + id, + options: {}, + session, + inbox, + ctx: scope.ctx, + get status() { return status }, + send: () => {}, + followup: () => {}, + steer: () => {}, + inject: () => {}, + cancel() { status = 'idle' }, + runMaintenance: task => task(new AbortController().signal), + whenIdle: () => Promise.resolve(), + } + ctx.agents.register(value) + return value +} + +describe('/feedback real Loader composition through cordis.yml', () => { + it('boots cordis.yml and records feedback without model-visible output', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-command-feedback-loader-')) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-agent'", + "- name: '@deepseek-ai/dsh-session'", + "- name: '@deepseek-ai/dsh-commands'", + "- name: '@deepseek-ai/dsh-command-feedback'", + '', + ].join('\n')) + + context = new Context() + context.baseUrl = pathToFileURL(root).href + '/' + await context.plugin(Loader) + context.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-agent', AgentRegistry], + ['@deepseek-ai/dsh-session', SessionStore], + ['@deepseek-ai/dsh-commands', CommandService], + ['@deepseek-ai/dsh-command-feedback', CommandFeedback], + ]) + context.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } }) + await context.loader.await() + + const owner = agent(context) + const signal = new AbortController().signal + + // Discoverable through the composed registry, as a UI adapter finds it. + expect(context.commands.list(owner).map(command => command.name)).toContain('feedback') + + const accepted = await context.commands.execute(owner, '/feedback the diff view is unreadable', signal) + expect(accepted?.result).toEqual({ kind: 'success', text: 'Feedback recorded.' }) + const rejected = await context.commands.execute(owner, '/feedback', signal) + expect(rejected?.result).toEqual({ + kind: 'error', + text: 'Feedback text is required. Usage: /feedback ', + }) + + // The domain event owns the payload; generic command bookkeeping omits it. + expect(owner.session.events.map(event => event.type)) + .toEqual(['command/run', 'feedback/record', 'command/done', 'command/run', 'command/done']) + const run = owner.session.events.find(event => event.type === 'command/run') + expect(run?.type === 'command/run' && Object.hasOwn(run.data, 'args')).toBe(false) + const feedback = owner.session.events.find(event => event.type === 'feedback/record') + expect(feedback?.type === 'feedback/record' && feedback.data.text).toBe('the diff view is unreadable') + expect(JSON.stringify(owner.session.events).match(/the diff view is unreadable/gu)).toHaveLength(1) + + // Nothing reached the model. + expect(owner.session.deriveMessages()).toEqual([]) + expect(owner.session.surface.nodes).toEqual([]) + }) +}) diff --git a/packages/feedback/command-feedback/tsconfig.json b/packages/feedback/command-feedback/tsconfig.json new file mode 100644 index 0000000000..0a99f13f01 --- /dev/null +++ b/packages/feedback/command-feedback/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../ui/commands" + }, + { + "path": "../../core/session" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml index 68ff459cf1..b29223b8e1 100644 --- a/packages/plan/plan-mode/README.i18n.yaml +++ b/packages/plan/plan-mode/README.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 packages/plan/plan-mode/README.md -README.md: 7273fa1a9be063e208596788eb4ca4f2bd3409a4 -README.zh.md: 0878319545593948fcf04fbc3de518641e1ecfbe +README.md: b22e218d1a0350d9dfb6e64fcaed426deaceb33e +README.zh.md: 57ac85577a690d5c560bec39bef851692be03efd diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index 7273fa1a9b..b22e218d1a 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -22,7 +22,7 @@ The Web client consumes the plugin-owned `/plan` command; other front doors may ## Session projection -When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md)), this package registers the `plan` projection unit under an injected child. The unit folds two event kinds: a `command/run` record named `plan` sets the wanted target (`off` → inactive, anything else → active), and `plan/mode` commits the logged state and clears it; every other event returns the same state reference. `view` derives `{ active, pending }`, where `pending` is true only while an outstanding selection differs from the logged state — a pure replay quantity, so host restarts, other tabs, and cold reads all recover it from the log alone (the `/plan` handler calls `set()` before any failing path, keeping the logged request and the run plane from forking). The key merges into `SessionProjectionMap` from `src/types.ts` (served to host consumers via `./types` and client aggregates via `./client`); the framework drives the unit and carriers serve the value on the history tail page and the `session/projection` push frame. Compositions without the registry are unaffected. +When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md)), this package registers the `plan` projection unit under an injected child. The unit folds two event kinds: a `command/run` record named `plan` with recorded `args` sets the wanted target (`off` → inactive, anything else → active), and `plan/mode` commits the logged state and clears it; every other event returns the same state reference. `view` derives `{ active, pending }`, where `pending` is true only while an outstanding selection differs from the logged state — a pure replay quantity, so host restarts, other tabs, and cold reads all recover it from the log alone (the `/plan` handler calls `set()` before any failing path, keeping the logged request and the run plane from forking). The key merges into `SessionProjectionMap` from `src/types.ts` (served to host consumers via `./types` and client aggregates via `./client`); the framework drives the unit and carriers serve the value on the history tail page and the `session/projection` push frame. Compositions without the registry are unaffected. ## Configuration diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md index 0878319545..57ac85577a 100644 --- a/packages/plan/plan-mode/README.zh.md +++ b/packages/plan/plan-mode/README.zh.md @@ -22,7 +22,7 @@ Web 客户端使用该插件提供的 `/plan` 命令;其他入口可以直接 ## 会话投影 -当组合挂载 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md))时,本包会在一个注入的子插件中注册 `plan` 投影单元。该单元折叠两类事件:名为 `plan` 的 `command/run` 记录会设置目标状态(`off` → 未激活,其余 → 激活),`plan/mode` 会提交已记录状态并清除该目标;其他任何事件都返回同一个状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在尚未落实的选择与已记录状态不同时为 true。该值完全由日志回放得出,因此 host 重启、其他标签页和冷读都能仅凭日志恢复它。`/plan` 处理器会在任何可能失败的路径之前调用 `set()`,避免已写入日志的请求与运行面分叉。key 由 `src/types.ts` 通过声明合并加入 `SessionProjectionMap`:host 消费方经 `./types` 获取,client 聚合经 `./client` 获取。框架负责驱动该单元,载体通过历史尾页和 `session/projection` 推送帧提供其值。未挂载注册表的组合不受影响。 +当组合挂载 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md))时,本包会在一个注入的子插件中注册 `plan` 投影单元。该单元折叠两类事件:名为 `plan` 且携带已记录 `args` 的 `command/run` 记录会设置目标状态(`off` → 未激活,其余 → 激活),`plan/mode` 会提交已记录状态并清除该目标;其他任何事件都返回同一个状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在尚未落实的选择与已记录状态不同时为 true。该值完全由日志回放得出,因此 host 重启、其他标签页和冷读都能仅凭日志恢复它。`/plan` 处理器会在任何可能失败的路径之前调用 `set()`,避免已写入日志的请求与运行面分叉。key 由 `src/types.ts` 通过声明合并加入 `SessionProjectionMap`:host 消费方经 `./types` 获取,client 聚合经 `./client` 获取。框架负责驱动该单元,载体通过历史尾页和 `session/projection` 推送帧提供其值。未挂载注册表的组合不受影响。 ## 配置 diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index aac726d128..c8dd974fd2 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -246,6 +246,7 @@ export class PlanModeService extends Service { init: () => ({ active: false, wanted: null }), apply: (state, event) => { if (event.type === 'command/run' && event.data.name === 'plan') { + if (event.data.args === undefined) return state const wanted = event.data.args.trim() !== 'off' return wanted === state.wanted ? state : { active: state.active, wanted } } diff --git a/packages/plan/plan-mode/tests/projection.spec.ts b/packages/plan/plan-mode/tests/projection.spec.ts index c26112c8fd..c504ff5df6 100644 --- a/packages/plan/plan-mode/tests/projection.spec.ts +++ b/packages/plan/plan-mode/tests/projection.spec.ts @@ -1,9 +1,9 @@ /** * The `plan` projection unit (session-projection RFC's complete example): a * double-event fold over the session log. `command/run` records named `plan` - * set the wanted target (`off` → false, anything else → true); `plan/mode` - * commits and clears it; `view` derives `{ active, pending }` where pending - * is true only while an outstanding selection differs from the logged state. + * with recorded input set the wanted target (`off` → false, anything else + * → true); `plan/mode` commits and clears it. `view` reports pending only + * while an outstanding selection differs from the logged state. * Pending is thereby a pure replay quantity — a cold fold answers it without * the service's in-memory intent. Composition without plan-mode has no `plan` * key; unloading the fiber removes it (HMR safety). @@ -88,6 +88,11 @@ describe('plan projection unit', () => { commandId: CommandId('other-1'), name: 'compact', args: '', source: { kind: 'user' }, }) expect(bench.values().plan).toEqual({ active: true, pending: false }) + // A command lifecycle with omitted input carries no plan selection. + bench.session.append('command/run', { + commandId: CommandId('plan-no-input'), name: 'plan', source: { kind: 'user' }, + }) + expect(bench.values().plan).toEqual({ active: true, pending: false }) runPlanCommand(bench.session, ' off', 1) expect(bench.values().plan).toEqual({ active: true, pending: true }) commitPlanMode(bench.session, false, 1) diff --git a/packages/ui/commands/README.i18n.yaml b/packages/ui/commands/README.i18n.yaml index 83cb7e4883..751344084c 100644 --- a/packages/ui/commands/README.i18n.yaml +++ b/packages/ui/commands/README.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 packages/ui/commands/README.md -README.md: a931628df28cb11ffb8e91068f7602a4a30cff94 -README.zh.md: e1ef7288ca7dca2945ec93d0ba23a3d1d6edf90c +README.md: 3105ae1a866e03f3c8f621bfe588df15ee38957e +README.zh.md: 704a2daefb65fde12ca85d1c9051ad762c5ccc70 diff --git a/packages/ui/commands/README.md b/packages/ui/commands/README.md index a931628df2..3105ae1a86 100644 --- a/packages/ui/commands/README.md +++ b/packages/ui/commands/README.md @@ -6,9 +6,9 @@ Plugin-owned human-command registry consumed by interactive UI adapters. The [pl ## Service contract -`ctx.commands.register(definition)` registers one lowercase command name, description, optional 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. +`ctx.commands.register(definition)` registers one lowercase command name, description, optional unstructured-input hint, optional `recordInput` policy, and abortable handler. `recordInput` defaults to true; a command whose authoritative domain event owns the payload sets it to false so `command/run` omits `args` instead of duplicating the input. 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)` 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 the settled `CommandExecution` (the normalized result plus the lifecycle pairing `commandId`) or `undefined` for invalid syntax or unknown names. A resolved command's lifecycle is logged on the receiving agent's session as the log-only pair `command/run` (before the handler, with a minted `commandId`, the parser's structured `name`/`args` split, and the issuing `CommandSource`) and `command/done` (at settlement, with the outcome kind and verbatim text; a thrown or aborted handler settles as `kind: 'error'`). Admission misses log nothing. Both are direct standalone appends on the receiving agent's session: no turn wraps them, and persistence drains them through ordinary checkpoints and teardown. +`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 the settled `CommandExecution` (the normalized result plus the lifecycle pairing `commandId`) or `undefined` for invalid syntax or unknown names. A resolved command's lifecycle is logged on the receiving agent's session as the log-only pair `command/run` (before the handler, with a minted `commandId`, the parser's structured name, the issuing `CommandSource`, and `args` unless `recordInput` is false) and `command/done` (at settlement, with the outcome kind and verbatim text; a thrown or aborted handler settles as `kind: 'error'`). Admission misses log nothing. Both are direct standalone appends on the receiving agent's session: no turn wraps them, and persistence drains them through ordinary checkpoints and teardown. `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/README.zh.md b/packages/ui/commands/README.zh.md index e1ef7288ca..704a2daefb 100644 --- a/packages/ui/commands/README.zh.md +++ b/packages/ui/commands/README.zh.md @@ -6,9 +6,9 @@ ## 服务契约 -`ctx.commands.register(definition)` 注册一个小写命令名称、描述、可选的非结构化输入提示,以及可中止的处理器。每个已注册命令都可供所有已组合的命令适配器使用;与某项部署不兼容的插件不会在此注册。普通上下文中的注册全局生效。在 `agent.ctx` 下挂载的命令生产插件会声明自身的 `commands` 注入,并创建精确限定到该 agent(智能体)的定义;该定义会遮蔽同名的全局定义。这种子级注入形态保留了 agent 作用域,同时不会让核心 agent loop(智能体循环)依赖 UI 服务。同一层中的名称重复会在注册时失败。每个 disposer 都是 Cordis effect 返回的确切 disposer;注册或移除命令时,系统会通知每个 `commands/change` 观察者,使运行中的适配器能够刷新发现结果。观察者失败会写入日志,既不能否决注册表变更,也不能阻止后续观察者运行。 +`ctx.commands.register(definition)` 注册一个小写命令名称、描述、可选的非结构化输入提示、可选的 `recordInput` 策略,以及可中止的处理器。`recordInput` 默认为 true;若载荷由命令的权威领域事件持有,该命令会将 `recordInput` 设为 false,让 `command/run` 省略 `args`,避免重复记录输入。每个已注册命令都可供所有已组合的命令适配器使用;与某项部署不兼容的插件不会在此注册。普通上下文中的注册全局生效。在 `agent.ctx` 下挂载的命令生产插件会声明自身的 `commands` 注入,并创建精确限定到该 agent(智能体)的定义;该定义会遮蔽同名的全局定义。这种子级注入形态保留了 agent 作用域,同时不会让核心 agent loop(智能体循环)依赖 UI 服务。同一层中的名称重复会在注册时失败。每个 disposer 都是 Cordis effect 返回的确切 disposer;注册或移除命令时,系统会通知每个 `commands/change` 观察者,使运行中的适配器能够刷新发现结果。观察者失败会写入日志,既不能否决注册表变更,也不能阻止后续观察者运行。 -`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令,返回已结算的 `CommandExecution`(规范化结果加生命周期配对 `commandId`);语法无效或名称未知时返回 `undefined`。已解析命令的生命周期会以 log-only 事件对的形式记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带新生成的 `commandId`、解析器的结构化 `name`/`args` 切分和发起方 `CommandSource`)与 `command/done`(结算时记录,携带结果类型与原样文本;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。两者都直接独立追加到接收 agent 的会话中:没有轮次包裹它们,持久化机制会在常规检查点和销毁期间排空这些事件。 +`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令,返回已结算的 `CommandExecution`(规范化结果加生命周期配对 `commandId`);语法无效或名称未知时返回 `undefined`。已解析命令的生命周期会以 log-only 事件对的形式记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带新生成的 `commandId`、解析器的结构化名称、发起方 `CommandSource`,以及 `args`(`recordInput` 为 false 时省略))与 `command/done`(结算时记录,携带结果类型与原样文本;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。两者都直接独立追加到接收 agent 的会话中:没有轮次包裹它们,持久化机制会在常规检查点和销毁期间排空这些事件。 `parseCommand()` 识别位于第 0 字节的斜杠、由小写字母、数字、`_` 或 `-` 构成的名称,以及名称后紧接输入末尾或空白的形式。它将名称后的每个字节作为 `rawInput` 返回,其中包括分隔空白;消费方负责各命令专用的语法,只能执行该语法允许的规范化。 diff --git a/packages/ui/commands/src/index.ts b/packages/ui/commands/src/index.ts index b1a5121243..b6dea581eb 100644 --- a/packages/ui/commands/src/index.ts +++ b/packages/ui/commands/src/index.ts @@ -71,6 +71,12 @@ export interface CommandDefinition { readonly description: string /** Optional free-form input hint advertised to capable clients. */ readonly input?: CommandInputDescriptor + /** + * Whether `command/run` records `rawInput`. Defaults to true. A command + * whose domain event owns the payload sets this false to avoid duplicating + * that payload in the session log. + */ + readonly recordInput?: boolean /** Execute against the receiving agent without sending the command to the model. */ readonly handler: (invocation: CommandInvocation) => CommandResult | Promise } @@ -127,9 +133,10 @@ declare module '@deepseek-ai/dsh-session' { * and `args` are `parseCommand`'s own split (name and verbatim rawInput, * separator whitespace included), so a consumer (a projection unit * folding its own command records, a rich command card) never re-parses - * a line. + * a line. `args` is absent when the definition sets `recordInput: false` + * because an authoritative domain event owns the input payload. */ - 'command/run': { commandId: CommandId; name: string; args: string; source: CommandSource } + 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource } /** * The paired command settled. `kind`/`text` carry the handler's verbatim * outcome (a thrown/aborted handler settles as `kind: 'error'` with the @@ -239,6 +246,7 @@ function normalizeDefinition(definition: CommandDefinition): RegisteredCommand { name: definition.name, description: definition.description, ...input === undefined ? {} : { input }, + ...definition.recordInput === undefined ? {} : { recordInput: definition.recordInput }, handler: definition.handler, }) const descriptor = Object.freeze({ @@ -357,7 +365,10 @@ export class CommandService extends Service { if (signal.aborted) throw abortError(signal) const commandId = this.mintCommandId() this.appendLifecycle(agent.session, 'command/run', { - commandId, name: parsed.name, args: parsed.rawInput, source: { kind: 'user' }, + commandId, + name: parsed.name, + ...command.definition.recordInput === false ? {} : { args: parsed.rawInput }, + source: { kind: 'user' }, }) const invocation = Object.freeze({ agent, rawInput: parsed.rawInput, signal }) let result: CommandResult diff --git a/packages/ui/commands/tests/commands.spec.ts b/packages/ui/commands/tests/commands.spec.ts index c910abf7d4..7412325ab6 100644 --- a/packages/ui/commands/tests/commands.spec.ts +++ b/packages/ui/commands/tests/commands.spec.ts @@ -320,6 +320,25 @@ describe('CommandService', () => { ]) }) + it('omits raw input from command/run when an authoritative domain event owns it', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + const seen = vi.fn(() => ({ kind: 'success' as const })) + ctx.commands.register({ + name: 'private', + description: 'Record privately', + recordInput: false, + handler: seen, + }) + + await ctx.commands.execute(agent, '/private keep this once', new AbortController().signal) + + expect(seen).toHaveBeenCalledWith(expect.objectContaining({ rawInput: ' keep this once' })) + const run = agent.session.events.find(event => event.type === 'command/run') + expect(run?.type).toBe('command/run') + expect(run?.type === 'command/run' && Object.hasOwn(run.data, 'args')).toBe(false) + }) + it('mints distinct monotonic commandIds across executions', async () => { const ctx = await mount() const { agent } = await mintAgentScope(ctx, 'a') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f34b3587b..d5fad7050e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -880,6 +880,9 @@ importers: '@deepseek-ai/dsh-command-compact': specifier: workspace:^ version: link:../../compact/command-compact + '@deepseek-ai/dsh-command-feedback': + specifier: workspace:^ + version: link:../../feedback/command-feedback '@deepseek-ai/dsh-command-goal': specifier: workspace:^ version: link:../../goal/command-goal @@ -3108,6 +3111,33 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/feedback/command-feedback: + devDependencies: + '@cordisjs/plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../ui/commands + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/fs/fs: devDependencies: '@deepseek-ai/dsh-brand': diff --git a/tsconfig.base.json b/tsconfig.base.json index cba3a9972d..64b32d41c2 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -84,6 +84,7 @@ "./packages/compact/*/src/invariant.ts", "./packages/context/*/src/invariant.ts", "./packages/goal/*/src/invariant.ts", + "./packages/feedback/*/src/invariant.ts", "./packages/guard/*/src/invariant.ts", "./packages/plan/*/src/invariant.ts", "./packages/subagent/*/src/invariant.ts", @@ -183,6 +184,7 @@ "./packages/compact/*/src", "./packages/context/*/src", "./packages/goal/*/src", + "./packages/feedback/*/src", "./packages/guard/*/src", "./packages/plan/*/src", "./packages/subagent/*/src", diff --git a/tsconfig.host.json b/tsconfig.host.json index 5772905a9e..662d0cf1d4 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -130,6 +130,7 @@ { "path": "./packages/goal/tool-goal" }, { "path": "./packages/goal/goal-session" }, { "path": "./packages/goal/command-goal" }, + { "path": "./packages/feedback/command-feedback" }, { "path": "./packages/context/time-context" }, { "path": "./packages/context/tmux-context" }, { "path": "./packages/context/session-reference" },