diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.i18n.yaml new file mode 100644 index 0000000000..61134679f7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.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/bug-fix/2026-08-06-plan-narrow-viewport-regression.md +2026-08-06-plan-narrow-viewport-regression.md: 945d014e0c51cbaf4080e72f50ee60763d851698 +2026-08-06-plan-narrow-viewport-regression.zh.md: 37060129364c65b02cc1729331fc7334797863db diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.md b/.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.md new file mode 100644 index 0000000000..945d014e0c --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.md @@ -0,0 +1,33 @@ +# Agent Note: narrow-viewport plan chip click-area regression test + +Status: implemented + +English | [中文](2026-08-06-plan-narrow-viewport-regression.zh.md) + +## Problem + +The external report dsh-external/issues#107 (clustered internally as deepseek-harness#1406) measured that at viewports between 760px and 850px the plan control and the model selector overlapped, with the model selector covering the plan control's click area so plan mode could not be left by mouse at 800×720. Its acceptance list asked for a browser regression test asserting that the plan center hit-tests to the plan button. + +The browser regression test reproduced the report on current master: at 800×720 the plan chip and the model trigger overlapped by 36.9px and the chip's center hit-tested to the trigger's label. The composer control row is `display: flex; justify-content: space-between` with `.trailing { flex: none }`: when the combined control width exceeds the card, the shrinking `.tools` group keeps its flow children inside its `min-width: 0` box, so the chip — the last flow child before the overflow — is painted over the trailing group. The plan-control form changed since the report (select → chip, `c20b988166`/`fe91919346`) and the row gained adaptive behavior (`c8c75ec891`, [web-composer-shared-width-axis](../feature/2026-08-04-web-composer-shared-width-axis.md)), but the row had no wrap, so the overlap survived both. + +## Decision + +The row wraps instead of shrinking its left group into the right group's area: `.row { flex-wrap: wrap }` plus `margin-left: auto` on `.trailing`, which re-anchors the trailing group (model + send) to the right edge of its wrapped line while `space-between` already pins it right on a single line. Wrapping is the acceptance's "wrap, fold, or re-arrange controls when space runs out" option, keeps every control at full width (no label folding that would hide the model name or the Plan wordmark), and holds at every viewport width by construction instead of at a calibrated container-query threshold. + +Add `apps/web/tests/plan-control-row.e2e.ts`: enter plan mode with the real `/plan` command (no argument — the command handler commits plan/mode active without a model round, the lifecycle-chrome precedent), so the test needs no model call in any mode and no API key in replay/refresh; a providers-only fixture mounts the model catalog without a script to consume. The file joins the host-plane e2e pairing like every sibling: excluded from the client graph in `apps/web/tsconfig.json` (it imports host-plane types) AND included in the host aggregate in `tsconfig.host.json`, so exactly one TypeScript program owns it — the pairing that also gives the lint type service its program. + +The geometry golden records stable facts — viewport membership on both axes and disjoint click areas — never absolute coordinates, whose pixel values depend on installed fonts and differ between macOS and Linux. The behavior assertions implement the acceptance directly: the click areas are disjoint, the click at the chip's center (Playwright's actionability check) leaves plan mode through the real command channel (`/plan off` via `commands.execute`), and the last `plan/mode` event in the session log flips inactive. + +## Alternatives considered + +**Seed a cold session (composer-tab-geometry pattern).** Rejected: the exit path executes `/plan off` through `commands.execute`, which needs the live agent a cold seeded session does not have; `connectFreshWorkspace` keeps one, matching the product's user path. + +**Pin absolute bounding boxes in the golden.** Rejected: chip and trigger widths depend on the installed fonts, so absolute coordinates would churn across platforms without a behavior change. + +**Reuse the plan-review fixture shape (exit_plan_mode review takeover).** Rejected: the takeover replaces the composer's control row, which is the surface under test. + +**Container-query label folding for the chip and/or the model trigger.** Rejected for the fix: two packages (ui-plan, ui-model) would need calibrated thresholds and the chip's own icon-only fold still leaves ~7px of overlap at the reported viewport unless the trigger folds too. Wrapping is one rule in one package and holds at every width. + +## Consequences + +Any future change to the control row layout — fonts, gaps, media or container queries — that re-introduces overlap or moves the chip out of the viewport on either axis fails this test. The test needs no API key in replay/refresh modes: plan mode toggles through the command handler without a model round, and a providers-only replay fixture (no recorded script, consumption check skipped) mounts the model directory so the trigger renders its real long label — the width that made the reported overlap measurable; the test asserts that label before measuring. The golden is compared in replay and record modes and rewritten in refresh mode. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.zh.md new file mode 100644 index 0000000000..3706012936 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 窄视口下 Plan chip 点击区域回归测试 + +Status: implemented + +[English](2026-08-06-plan-narrow-viewport-regression.md) | 中文 + +## 问题 + +外部报告 dsh-external/issues#107(内部聚类为 deepseek-harness#1406)测得视口宽度在 760px 到 850px 之间时 Plan 控件与模型选择器发生重叠,模型选择器覆盖 Plan 控件的点击区域,导致在 800×720 下无法用鼠标退出 Plan 模式。其验收清单要求增加浏览器回归测试,断言 Plan 中心命中 Plan 按钮。 + +浏览器回归测试在当前 master 上复现了报告:800×720 下 Plan chip 与模型 trigger 重叠 36.9px,chip 中心命中 trigger 的 label。composer 控制行是 `display: flex; justify-content: space-between` 且 `.trailing { flex: none }`:当控件总宽超过卡片时,可收缩的 `.tools` 组把流内子项留在 `min-width: 0` 的盒内,于是 chip——溢出前最后一个流内子项——被绘制到 trailing 组上方。报告以来 Plan 控件形态已变(select → chip,`c20b988166`/`fe91919346`),控制行也获得过自适应能力(`c8c75ec891`,[web-composer-shared-width-axis](../feature/2026-08-04-web-composer-shared-width-axis.md)),但该行没有换行,重叠在两次重构后依然存在。 + +## 决策 + +控制行换行而不是把左侧组收缩进右侧组的区域:`.row { flex-wrap: wrap }` 加上 `.trailing` 的 `margin-left: auto`——后者把 trailing 组(模型选择 + 发送)重新锚定到换行后的右缘,单行时 `space-between` 已把它钉在右侧。换行是验收中"空间不足时允许换行、折叠或重新排列控件"的选项,保持每个控件全宽(不做会隐藏模型名或 Plan 字样的 label 折叠),并且按构造在所有视口宽度下成立,而非依赖标定的容器查询阈值。 + +新增 `apps/web/tests/plan-control-row.e2e.ts`:通过真实 `/plan` 命令(无参数——命令 handler 不经模型回合即提交 plan/mode active,lifecycle-chrome 先例)进入 Plan 模式,因此测试在任何模式下都无需模型调用,仅在 replay/refresh 下无需 API key;providers-only fixture 挂载模型目录而无脚本可消费。该文件与所有同类 host 平面 e2e 一样采用成对登记:在 `apps/web/tsconfig.json` 的 exclude 列表(它导入 host 平面类型,client 图绝不编译它),同时在 `tsconfig.host.json` 的 host 聚合 include 中——恰好一个 TypeScript 程序拥有它,这也是 lint 类型服务获得程序的配对方式。 + +几何 golden 记录稳定事实——两个轴上的视口内位置与点击区域不相交——绝不记录绝对坐标,其像素值依赖安装字体且在 macOS 与 Linux 间不同。行为断言直接实现验收:点击区域不相交、点击 chip 中心(Playwright 的可操作性检查)经真实命令通道(`commands.execute` 执行 `/plan off`)退出 Plan 模式,且会话日志中最后一条 `plan/mode` 事件翻转为 inactive。 + +## 备选方案 + +**冷会话 seed(composer-tab-geometry 模式)。** 否决:退出路径经 `commands.execute` 执行 `/plan off`,需要 live agent,而冷 seed 会话没有;`connectFreshWorkspace` 保留一个,与产品的用户路径一致。 + +**golden 固定绝对 bounding box。** 否决:chip 与 trigger 宽度依赖安装字体,绝对坐标会在平台间漂移而不反映行为变化。 + +**复用 plan-review fixture 形态(exit_plan_mode review takeover)。** 否决:takeover 会替换 composer 控制行,而被测表面正是控制行。 + +**chip 与/或模型 trigger 的容器查询 label 折叠。** 否决(作为修复):两个包(ui-plan、ui-model)需要各自标定阈值,且 chip 单独折叠为 icon-only 在报告视口下仍剩约 7px 重叠,除非 trigger 也折叠。换行是一个包中的一条规则,且在所有宽度下成立。 + +## 后果 + +任何改变控制行布局的后续改动——字体、间距、媒体查询或容器查询——一旦重新引入重叠或把 chip 沿任一轴移出视口,本测试即失败。测试在 replay/refresh 模式下无需 API key:Plan 模式经命令 handler 切换,不经模型回合;providers-only replay fixture(无录制脚本,跳过消费检查)挂载模型目录,使触发器渲染真实的长标签——正是使报告重叠可测量的宽度;测试在测量前断言该标签。golden 在 replay 与 record 模式下比较,在 refresh 模式下重写。 diff --git a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.i18n.yaml b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.i18n.yaml new file mode 100644 index 0000000000..56eafca036 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.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-08-17-command-image-attachment-envelope.md +2026-08-17-command-image-attachment-envelope.md: 328a3fffa1d8db3ac9be42983965ef7f9578dec9 +2026-08-17-command-image-attachment-envelope.zh.md: bb135d218f156aaa36e3f9f52ed36019b68b56c3 diff --git a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md new file mode 100644 index 0000000000..328a3fffa1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md @@ -0,0 +1,46 @@ +# Agent Note: Command image-attachment envelope + +Status: implemented + +English | [中文](2026-08-17-command-image-attachment-envelope.zh.md) + +## Problem + +The Web composer submits one envelope — draft text, attached images, and delivery mode — but the two submission planes consumed it asymmetrically. A plain message rode `defaultSink → conversation.sendSession`, which serialized the images into prompt content and cleared them on success. A claimed slash command rode `claim.submit(args, actx)`, a text-only transaction: `/goal rebuild the cathedral` with four reference photos executed the command, cleared the draft, and silently stranded the images in the composer rail. The model never saw them, and no surface said so. The defect was contract-level, not a missed call site: nothing in the claim, the adjudication, or the host executor modeled attachments, so any command could consume the text half of a submission and drop the rest. + +Merging the two planes was not on the table — the [plugin command registration Agent Note](2026-07-19-plugin-command-registration.md) deliberately keeps human commands out of the model plane, and that separation is correct. The gap was that the envelope fractured at the plane fork. + +## Decision + +The submission envelope is modeled end to end, and every command route either consumes it whole or refuses it loudly. + +**Declaration.** `CommandDefinition.input.images: boolean` (absent = false) declares whether composer images may accompany an invocation. The flag rides the frozen `CommandDescriptor` through `commands/list` to every client, onto the minted `CommandClaim` (`images: true`), and into the input machine's published claim snapshot. + +**Generic identity, image-specific payload.** Browser drafts and durable references already use `DraftAttachmentId` and `AttachmentId`; the command RPC carries encoded bytes rather than an image identifier. The wire remains `EncodedImageAttachment[]`, and the declaration remains `input.images`, while images are the only non-text attachment with defined admission and model-block semantics. + +**Executor enforcement.** `CommandRuntime.execute(agent, line, images, signal)` carries the submission's base64 images (`EncodedImageAttachment` from `@deepseek-ai/dsh-attachment/types`). The executor — not the composer — enforces the declaration: images to a non-declaring command, an absent attachment store, and an exceeded batch limit each settle as a logged `command/done` error before the handler runs. Admission goes through the attachment package's `admitEncodedImages` — the shared wire entry that enforces canonical base64 and delegates batch admission (limits, validation, ordered commit) to `AttachmentStore.saveImages` — so both wire endpoints (prompt RPC and command executor) share one sequence and a rejected batch publishes no durable object. An admitted batch reaches the handler as frozen ordered `ImageBlock`s on `invocation.attachments`. + +**Producer-owned model visibility.** The registry never schedules the images itself. `/goal` submits one `agent.followup` user message — image blocks plus the fixed text `Reference images for the goal objective.` — after a successful create or edit, so later goal rounds read the images from ordinary session history and the goal domain stores no attachment state. `/plan ` folds the images into its steered text message, while bare `/plan` steers an image-only user message because the images may contain the whole task. Producer control forms with no model input (`/goal pause`, `/plan off`) return a direct error and keep the composer's images in place. The plan projection treats `command/run` as a candidate and drops it on a paired `command/done` error, so a rejected image-carrying `/plan off` cannot leave a pending exit. + +**Composer refusal is a visible banner, everything retained.** ui-commands' `matchEnter` receives a `SubmitEnvelope` (image count) from adjudication and throws a localized `notice.imagesUnsupported` refusal for every enter route that cannot consume images: contribution popups, decorated popups, non-declaring claims, and bare detached executes. The input machine publishes one error notice, which the composer renders through its transient Toast banner with draft and images untouched. A pre-claimed submit (space/menu claim) is gated in the facade with the same copy from the `conversation` namespace. On the accepting path the facade serializes the draft images through the hub's `commandImages` plumbing, passes them to `claim.submit`, and clears plus releases them only on a success outcome; an error result (including a producer grammar rejection) keeps them. + +## Testing + +Registry executor enforcement, admission failure settlement, and frozen invocation attachments are covered in `packages/interaction/commands/tests/commands.spec.ts`; batch admission ordering and limits in `packages/attachment/attachment/tests/admission.spec.ts`; producer behavior in `packages/goal/command-goal/tests/command-goal.spec.ts` and `packages/plan/plan-mode/tests/plan-mode.spec.ts`; client refusal and consumption paths in the ui-commands, ui-conversation, and ui-input-trigger client suites; and the assembled-application flow in the apps/web keyless lanes. + +## Alternatives considered + +- **Block commands whenever images are attached (no acceptance path)** — rejected: predictable, but `/goal` with reference images is the motivating use case; the user's images would have no route to the model at all. +- **Auto-send stranded images as a follow-up user message after any command** — rejected: surprising for host-state commands (`/model`, `/compact`), and it moves the message contract from the producer to the composer, against the command registry's "producer owns model-visible work" rule. +- **Store attachment references in the goal domain and render them into round prompts** — rejected: requires durable goal schema changes and either duplicates image blocks into every round prompt or adds round-one-only prompt shape; the round-prompt invariant would need attachment state. One ordinary logged user message achieves the same model visibility. +- **Consume images on any command success regardless of grammar** — rejected: `/goal pause` with images attached would silently discard them, recreating the original defect one layer deeper. Consumption is tied to the producer's explicit success, and grammar misfits return errors. +- **Keep enforcement client-side only** — rejected: schema omission is not enforcement; direct RPC callers could bypass the composer. The executor settles the declaration itself. +- **Generalize the command wire to a multimedia identifier** — rejected: the two identifiers are already attachment-generic, while the wire transports bytes and its image-specific fields state the admission rules the Host enforces. Files and videos lack shared admission and model-visible semantics, and an untagged multimedia identifier would not supply them. A second supported attachment kind is the reintroduction condition; the command envelope then widens to a tagged attachment union and commands declare the accepted kinds while retaining `AttachmentId`. + +## Consequences + +- No command route can consume a submission's text and strand its images: the contract forces whole-envelope consumption or a visible refusal, for current and future commands alike. +- The commands package now depends on `dsh-attachment` and `dsh-llm`, and `commands/execute` carries a required `images` wire parameter — every caller states its envelope explicitly. +- `/goal` and `/plan` gain reference-image input at the cost of one extra logged user message (goal) and image blocks in the steered message (plan), including an image-only message for bare `/plan`; all are billed like any image prompt. +- Menu-pick popup flows do not consult the envelope: picking a popup command from the menu while images are attached leaves the images visibly in the rail rather than refusing the interaction. Enter-submission is the enforced envelope boundary. +- "A rejected batch publishes no durable object" covers exactly the pre-admission settlements (declaration, missing store, batch limit). A handler-level grammar rejection (`/goal pause` with images) and a post-admission cancellation settle AFTER the batch committed, leaving content-addressed objects without a referencing session event — harmless under sha256 dedup and the attachment store's deferred reference-aware GC, but not "no object was written". diff --git a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md new file mode 100644 index 0000000000..bb135d218f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md @@ -0,0 +1,46 @@ +# Agent Note: Command image-attachment envelope + +Status: implemented + +[English](2026-08-17-command-image-attachment-envelope.md) | 中文 + +## Problem + +Web composer 的一次提交是一个信封——草稿文本、已附加图片、投递模式——但两条提交平面对它的消费是不对称的。普通消息走 `defaultSink → conversation.sendSession`,图片被序列化进 prompt 内容并在成功后清除。被 claim 的斜杠命令走 `claim.submit(args, actx)`,一个纯文本事务:`/goal rebuild the cathedral` 带四张参考照片时,命令执行、草稿清空,图片却静默滞留在 composer 附件栏。模型从未看到它们,也没有任何界面提示。这个缺陷在契约层面而非某个漏掉的调用点:claim、裁决、宿主执行器都没有建模附件,因此任何命令都可能消费提交的文本一半而丢弃其余部分。 + +合并两个平面从未在考虑范围内——[插件命令注册 Agent Note](2026-07-19-plugin-command-registration.md)刻意让人类命令留在模型平面之外,这个分离是正确的。问题在于信封在平面分叉处被拆散了。 + +## Decision + +提交信封被端到端建模,每条命令路径要么整体消费它,要么响亮拒绝。 + +**声明。**`CommandDefinition.input.images: boolean`(缺省为 false)声明 composer 图片是否可以随调用提交。该标志随冻结的 `CommandDescriptor` 经 `commands/list` 到达每个客户端,进入铸造出的 `CommandClaim`(`images: true`),再进入输入状态机发布的 claim 快照。 + +**通用标识,图片专用载荷。**浏览器草稿与持久化引用已经使用 `DraftAttachmentId` 和 `AttachmentId`;命令 RPC 传输的是编码字节,而非图片标识。图片仍是唯一已经定义准入规则和模型块语义的非文本附件,因此 wire 保持 `EncodedImageAttachment[]`,声明保持 `input.images`。 + +**执行器强制。**`CommandRuntime.execute(agent, line, images, signal)` 携带本次提交的 base64 图片(来自 `@deepseek-ai/dsh-attachment/types` 的 `EncodedImageAttachment`)。强制执行声明的是执行器而非 composer:把图片发给未声明的命令、附件存储缺失、批量超限,都会在处理器运行前以记录在案的 `command/done` 错误结算。准入经由 attachment 包的 `admitEncodedImages`——共享 wire 入口,强制执行规范 base64 并把批量准入(限额、校验、有序提交)委托给 `AttachmentStore.saveImages`——使两个 wire 端点(prompt RPC 与命令执行器)共享同一序列,被拒绝的批量不会发布任何持久化对象。通过准入的批量以冻结的有序 `ImageBlock` 数组挂在 `invocation.attachments` 上交给处理器。 + +**模型可见性由生产方负责。**注册表自身绝不调度这些图片。`/goal` 在 create 或 edit 成功后通过 `agent.followup` 提交一条用户消息——图片块加固定文本 `Reference images for the goal objective.`——后续 Goal Round 从普通会话历史读取图片,goal 领域不存储附件状态。`/plan ` 把图片并入其 steer 的文本消息;不带参数的 `/plan` 则 steer 一条只含图片的用户消息,因为图片可能包含全部任务内容。不会发送模型输入的控制形式(`/goal pause`、`/plan off`)会直接返回错误,composer 的图片原地保留。plan 投影会把 `command/run` 视为候选选择,并在配对的 `command/done` 报错时丢弃它,因此被拒绝的带图 `/plan off` 不会留下待退出状态。 + +**composer 的拒绝是可见横幅,一切保留。**ui-commands 的 `matchEnter` 从裁决收到 `SubmitEnvelope`(图片数量),对每条无法消费图片的回车路径抛出本地化的 `notice.imagesUnsupported` 拒绝:contribution 弹窗、decoration 弹窗、未声明的 claim、bare 分离执行。输入状态机发布一条错误通知,composer 通过瞬态 Toast 横幅呈现它,草稿与图片不动。已 claim 状态下的提交(空格或菜单 claim)由 facade 用 `conversation` 命名空间的同款文案把关。接受路径上,facade 经 hub 的 `commandImages` 管道序列化草稿图片、传给 `claim.submit`,仅在成功 outcome 后清除并释放;错误结果(包括生产方的语法拒绝)保留它们。 + +## Testing + +注册表执行器强制、准入失败结算、冻结的调用附件由 `packages/interaction/commands/tests/commands.spec.ts` 覆盖;批量准入顺序与限额在 `packages/attachment/attachment/tests/admission.spec.ts`;生产方行为在 `packages/goal/command-goal/tests/command-goal.spec.ts` 与 `packages/plan/plan-mode/tests/plan-mode.spec.ts`;客户端拒绝与消费路径在 ui-commands、ui-conversation、ui-input-trigger 客户端套件;组装后应用流程在 apps/web 的 keyless 通道。 + +## Alternatives considered + +- **附加图片时一律拦截命令(没有接受路径)**——被拒绝:可预测,但带参考图的 `/goal` 正是驱动这次修复的用例,用户的图片将完全没有通往模型的路径。 +- **任何命令后把滞留图片自动作为后续用户消息发送**——被拒绝:对宿主状态命令(`/model`、`/compact`)令人意外,且把消息契约从生产方挪到 composer,违反命令注册表「生产方负责模型可见工作」的规则。 +- **在 goal 领域存储附件引用并渲染进 Round 提示词**——被拒绝:需要持久化 goal schema 变更,且要么把图片块复制进每轮提示词,要么引入仅首轮的提示词形态;round 提示词不变量将需要附件状态。一条普通的已记录用户消息达到同样的模型可见性。 +- **只要命令成功就消费图片,不管语法**——被拒绝:`/goal pause` 带图会把图片静默丢弃,在更深一层重演原始缺陷。消费与生产方的显式成功绑定,语法不匹配返回错误。 +- **只在客户端强制**——被拒绝:schema 省略不是强制执行;直接 RPC 调用方可以绕过 composer。执行器自己结算声明。 +- **把命令 wire 泛化成多媒体标识**——被拒绝:两个标识已经是附件通用类型,wire 传输的是字节,其图片专用字段明确表达了 Host 强制执行的准入规则。文件和视频尚无共同的准入规则与模型可见语义,一个不带类型标记的多媒体标识也无法提供这些信息。出现第二种受支持附件时再引入泛化:命令信封扩展为带类型标记的附件联合类型,命令声明接受的类型,`AttachmentId` 保持不变。 + +## Consequences + +- 任何命令路径都不可能消费提交的文本而滞留图片:契约强制整信封消费或可见拒绝,对现有与未来命令一体适用。 +- commands 包新增对 `dsh-attachment` 与 `dsh-llm` 的依赖,`commands/execute` 携带必填的 `images` wire 参数——每个调用方都显式陈述其信封。 +- `/goal` 与 `/plan` 获得参考图输入,代价是一条额外的已记录用户消息(goal)与 steer 消息中的图片块(plan),其中不带参数的 `/plan` 会产生只含图片的消息;所有这些输入的计费都与常规图片提示词相同。 +- 菜单点选的弹窗流程不查询信封:附有图片时从菜单点选弹窗命令,图片会可见地留在附件栏,而不是拒绝该交互。回车提交是被强制执行的信封边界。 +- 「被拒绝的批量不发布任何持久化对象」只覆盖准入前的三种结算(声明、存储缺失、批量超限)。handler 级语法拒绝(如 `/goal pause` 带图)与准入后取消发生在批量已提交之后,会留下没有会话事件引用的内容寻址对象——在 sha256 去重与附件存储延后的引用感知 GC 下无害,但并非「未写入任何对象」。 diff --git a/apps/web/tests/command-image-envelope.snapshot.ts b/apps/web/tests/command-image-envelope.snapshot.ts new file mode 100644 index 0000000000..20aeb3a040 --- /dev/null +++ b/apps/web/tests/command-image-envelope.snapshot.ts @@ -0,0 +1,97 @@ +// @vitest-environment jsdom +// The command image-attachment envelope over the BUILT client graph (real +// bundles via AppWebEntry, keyless FixtureApiClient transport): an enter +// submission carrying composer images resolves only through a command whose +// descriptor declares `input.images`. A non-declaring command refuses with +// one composer error banner and everything retained; a declaring command +// consumes the images — serialized through the real draft-image chain into +// the commands/execute payload — and clears the composer on success, including +// when the image is the whole `/plan` task. +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { expect, it } from 'vitest' +import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts' + +installAssembledBootEnv() + +/** Open a fresh fixture session and return its composer textarea. */ +async function freshComposer(): Promise { + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + const start = tree.querySelector('button[aria-label="New session in fixture"]') + if (start === null) throw new Error('fixture Workspace new-session action missing') + fireEvent.click(start) + return await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) as HTMLTextAreaElement +} + +/** Paste one tiny PNG into the composer and wait for its rail thumbnail. */ +async function pasteImage(textarea: HTMLTextAreaElement, name: string): Promise { + const image = new File([new Uint8Array([137, 80, 78, 71])], name, { type: 'image/png' }) + fireEvent.paste(textarea, { + clipboardData: { + items: [{ kind: 'file', type: 'image/png', getAsFile: () => image }], + getData: () => '', + }, + }) + await waitFor(() => { + const rail = document.querySelector('[role="group"][aria-label="Pending images"]') + if (rail === null) throw new Error('attachment rail missing') + expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toContain(name) + }, { timeout: 5_000 }) +} + +it('refuses an image-carrying submit to a non-declaring command and keeps draft and images', async () => { + mountAssembledApp() + const textarea = await freshComposer() + await pasteImage(textarea, 'ref.png') + + // /echo is a leadingInput fixture command without `input.images`. + fireEvent.change(textarea, { target: { value: '/echo hello' } }) + fireEvent.keyDown(textarea, { key: 'Enter' }) + + // The refusal rides the same transient error banner as other composer + // failures; session activity remains on its separate status live region. + const notice = await waitFor(() => { + const el = [...document.querySelectorAll('[role="alert"]')] + .find(candidate => candidate.textContent?.includes('image attachments') ?? false) + if (el === undefined) throw new Error('composer refusal banner missing') + return el + }, { timeout: 5_000 }) + expect(notice.textContent).toBe('/echo does not accept image attachments; remove them first') + expect([...document.querySelectorAll('[role="status"]')] + .some(candidate => candidate.textContent?.includes('image attachments') ?? false)).toBe(false) + // The whole envelope is retained: draft text and the rail thumbnail. + expect(textarea.value).toBe('/echo hello') + const rail = document.querySelector('[role="group"][aria-label="Pending images"]') + expect([...(rail?.querySelectorAll('img') ?? [])].map(img => img.getAttribute('alt'))).toEqual(['ref.png']) +}) + +it('consumes images through a declaring command and clears the composer on success', async () => { + mountAssembledApp() + const textarea = await freshComposer() + await pasteImage(textarea, 'goal-ref.png') + + // /goal declares `input.images` in the fixture catalog; the claim submit + // serializes the pasted bytes and the fixture executor admits them. + fireEvent.change(textarea, { target: { value: '/goal rebuild the cathedral' } }) + fireEvent.keyDown(textarea, { key: 'Enter' }) + + await waitFor(() => { + expect(textarea.value).toBe('') + expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull() + }, { timeout: 5_000 }) +}) + +it('submits a bare /plan with an image as an image-only plan request', async () => { + mountAssembledApp() + const textarea = await freshComposer() + await pasteImage(textarea, 'plan-task.png') + + fireEvent.change(textarea, { target: { value: '/plan' } }) + fireEvent.keyDown(textarea, { key: 'Enter' }) + + await waitFor(() => { + expect(textarea.value).toBe('') + expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull() + }, { timeout: 5_000 }) + expect([...document.querySelectorAll('[role="alert"]')] + .some(candidate => candidate.textContent?.includes('/plan') ?? false)).toBe(false) +}) diff --git a/apps/web/tests/plan-control-row.e2e.ts b/apps/web/tests/plan-control-row.e2e.ts new file mode 100644 index 0000000000..691c004c5c --- /dev/null +++ b/apps/web/tests/plan-control-row.e2e.ts @@ -0,0 +1,148 @@ +// Web e2e scenario: at the 800×720 viewport the plan chip and the model +// trigger keep disjoint click areas, and clicking the chip at its center +// leaves plan mode through the real command channel. This is the browser +// regression the external report asked for (dsh-external/issues#107 → +// deepseek-harness#1406): "increase an 800×720 browser regression test and +// assert that the plan center hits the plan button". +// +// Plan mode is entered through the real /plan command with no argument: +// the command handler commits plan/mode active on the live agent without a +// model round (the lifecycle-chrome precedent), so the test needs no model +// call in any mode and no API key in replay/refresh; a providers-only +// fixture mounts the model catalog without a script to consume. Plan state +// folds from the session log (`plan/mode`, last one wins); the chip executes +// /plan off through commands.execute, which needs the live agent +// connectFreshWorkspace keeps. +// +// The geometry golden records stable facts — viewport membership on both +// axes for the chip and the trigger, and disjoint click areas — never +// absolute coordinates, whose pixel values depend on installed fonts and +// differ between macOS and Linux. The center hit-test is Playwright's +// actionability check: clicking the chip fails in a real engine when the +// element center does not receive pointer events. jsdom resolves no layout, +// so only a real engine can answer any of these facts. +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +// Type-only: pulls the plan/mode SessionEventMap merge so the discriminant +// filter below types as the plan-mode event in the host aggregate. +import type {} from '@deepseek-ai/dsh-plan-mode' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { + assertFixtureInventory, compareOrRefreshGolden, + launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/plan-narrow-viewport', import.meta.url)) +const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') +const LAYOUT_EXPECTED = join(SNAPSHOT_DIR, 'layout.expected.md') +const MODE = webSnapshotMode() + +/** The reported viewport: 800×720, where the composer card is 448px wide at 0.0.1. */ +const VIEWPORT = { width: 800, height: 720 } as const + +/** Chip aria-label on the English page; the seat renders only while plan is the effective target. */ +const CHIP_ARIA = 'Plan mode on, press to turn off' + +describe('web e2e: plan chip click area at the narrow viewport', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + const sessionEvents: SessionEvent[] = [] + + beforeAll(async () => { + // replayProvidersOnly mounts the provider catalog without any recorded + // script to consume (no model call happens — the /plan command never + // steers a message), so the model trigger renders its real long label, + // which is what made the reported overlap measurable. + scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayProvidersOnly: true }) + scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) + browser = await chromium.launch() + page = await newEnglishPage(browser, VIEWPORT.height) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + await page.setViewportSize(VIEWPORT) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('keeps the plan chip and model trigger disjoint and exits plan mode by click', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-plan-narrow-viewport')) + const input = page.locator('textarea').first() + await input.waitFor({ timeout: 10_000 }) + await input.fill('/plan ') + await input.press('Enter') + + // The command handler commits plan/mode active immediately (no model + // round), so the chip renders and the composer control row — the surface + // under test — is the one visible. + const chip = page.getByRole('button', { name: CHIP_ARIA }) + const trigger = page.getByRole('button', { name: /Select model/ }) + await chip.waitFor({ timeout: 30_000 }) + await trigger.waitFor({ timeout: 10_000 }) + // The regression depends on the real model label width: a bare fallback + // trigger would fit beside the chip even on the pre-fix layout. The + // directory loads asynchronously, so poll for the real label. + await expect.poll(() => trigger.getAttribute('aria-label'), { timeout: 10_000 }).toContain('DeepSeek-V4-Flash') + const chipBox = await chip.boundingBox() + const triggerBox = await trigger.boundingBox() + expect(chipBox).not.toBeNull() + expect(triggerBox).not.toBeNull() + + // The reported acceptance as numbers: both controls in viewport and + // disjoint click areas (a non-zero overlap would fail), and — in the + // click below — the chip center receiving the pointer. + const chipInViewport = chipBox!.x >= 0 && chipBox!.x + chipBox!.width <= VIEWPORT.width + && chipBox!.y >= 0 && chipBox!.y + chipBox!.height <= VIEWPORT.height + const triggerInViewport = triggerBox!.x >= 0 && triggerBox!.x + triggerBox!.width <= VIEWPORT.width + && triggerBox!.y >= 0 && triggerBox!.y + triggerBox!.height <= VIEWPORT.height + const overlapLeft = Math.max(chipBox!.x, triggerBox!.x) + const overlapTop = Math.max(chipBox!.y, triggerBox!.y) + const overlapRight = Math.min(chipBox!.x + chipBox!.width, triggerBox!.x + triggerBox!.width) + const overlapBottom = Math.min(chipBox!.y + chipBox!.height, triggerBox!.y + triggerBox!.height) + const overlapArea = Math.max(0, overlapRight - overlapLeft) * Math.max(0, overlapBottom - overlapTop) + + const golden = [ + '# Plan chip and model trigger at the 800×720 viewport', + '', + '- Plan chip fully in viewport: ' + (chipInViewport ? 'true' : 'false'), + '- Model trigger fully in viewport: ' + (triggerInViewport ? 'true' : 'false'), + '- Click areas disjoint: ' + (overlapArea === 0 ? 'true' : 'false'), + ].join('\n').trimEnd() + await compareOrRefreshGolden(LAYOUT_EXPECTED, golden, MODE) + expect(overlapArea).toBe(0) + expect(chipInViewport).toBe(true) + expect(triggerInViewport).toBe(true) + + // Exit through the real command channel: the click at the chip's center + // executes /plan off and the folded projection flips inactive, so the chip + // unmounts. Playwright's click() targets the element center by default and + // its actionability check fails the click when that point is covered by + // the model trigger — the reported bug as a failing click rather than a + // coordinate probe. + await chip.click() + await expect.poll(() => page.getByRole('button', { name: CHIP_ARIA }).count(), { timeout: 15_000 }).toBe(0) + // The click must have committed the exit: the last plan/mode event flips + // inactive (the /plan command's entry event stays active:true earlier in + // the log, so the pair proves the exit and not just the entry). + const planModes = sessionEvents.filter( + (event): event is SessionEvent<'plan/mode'> => event.type === 'plan/mode', + ) + expect(planModes.at(-1)?.data.active).toBe(false) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 200_000) + + it('keeps the snapshot inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'layout.expected.md']) + }) +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index f1ba62d881..ac2ca402e6 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -22,7 +22,7 @@ // llm seam post-boot with installLlmReplay on the settled root ctx // (the plugin-row path discards the ReplayHandle; the direct install keeps // assertConsumed for the teardown fixture-consumption check). -import { existsSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -179,7 +179,11 @@ export interface WebScaffold { harnessHome: string /** Await a settled turn end: in-process turn/end, then the agent's idle flip (which follows the persistence flush). */ whenTurnSettled(timeoutMs?: number): Promise - /** Tear everything down; asserts the replay fixture was fully consumed first (replay/refresh). */ + /** + * Tear everything down; asserts the replay fixture was fully consumed first + * (replay/refresh), unless booted with replayProvidersOnly (whose fixture + * is validated call-free at boot). + */ close(): Promise } @@ -196,9 +200,20 @@ export interface LaunchOptions { * in replay/refresh modes; ignored in record mode (the real adapter * answers). Omit for scenarios issuing no model calls — a stray stream then * fails loud with NO_ADAPTER (llm-deepseek is disabled and no replay row - * mounts). + * mounts). With {@link replayProvidersOnly}, the fixture must record no + * model calls (its header alone mounts the catalog). */ replayFixture?: string + /** + * Mount the replay provider catalog (the model directory the UI shows) + * without consuming any recorded script: for scenarios that never call a + * model but need the real provider/model labels rendered. Requires + * {@link replayFixture} whose log records no model calls, and rejects + * {@link replayOverride} and {@link replayChildFixtures}; the teardown + * consumption check is skipped for this mode. `replayFixture` without this + * flag keeps the consumption check. + */ + replayProvidersOnly?: boolean /** * Recorded child logs assigned in child creation order. Each child owns its * own positional replay cursor across initial and continuation turns. @@ -547,6 +562,36 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise ( + event.type === 'assistant/chunk' || event.type === 'request/header' || event.type === 'tool/call' + )) + if (hasModelCall) { + throw new Error('replayProvidersOnly fixture must record no model calls') + } + } if (mode !== 'record' && options.replayFixture !== undefined) { replayHandle = installLlmReplay(ctx, { file: options.replayFixture, @@ -608,11 +653,14 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index ddf5a8de3e..7a4a162142 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1381,7 +1381,7 @@ export interface PlanModeConfig { } ``` -来源:[`packages/plan/plan-mode/src/index.ts:70`](../packages/plan/plan-mode/src/index.ts) +来源:[`packages/plan/plan-mode/src/index.ts:71`](../packages/plan/plan-mode/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 93519e4a57..d933130f59 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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 docs/event-producer-consumer.md -event-producer-consumer.md: c7b474a15c701781a70019f0703c0d60da87bcae -event-producer-consumer.zh.md: 2e0667288ea44f83b7030e686db194f04d550a9b +event-producer-consumer.md: c906ee6329fac66e7391c266213dd150dd5b8e09 +event-producer-consumer.zh.md: 77bf401b7215bd263c0d84f04e0eabe6b28b7915 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index c7b474a15c..c906ee6329 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -22,7 +22,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, [`team`](../packages/experimental/team) | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | -| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:72`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | +| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:379`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:385`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:391`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 2e0667288e..77bf401b72 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -24,7 +24,7 @@ | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, [`team`](../packages/experimental/team) | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | -| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:72`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | +| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:379`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:385`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:391`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 92e1cd300f..729ab6e39a 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: 44d9891bfd0d81c1a7f6ce927fc72bf861ce74d1 -module-graph.zh.md: 664e85e24b8cfc4313100476d9d658ba487b0810 +module-graph.md: 4b902b430b0680866bd95c4c9f2004fd68e9372d +module-graph.zh.md: 6198edf4e05c892f57d132caf8492cb8c3a7e047 diff --git a/docs/module-graph.md b/docs/module-graph.md index 44d9891bfd..4b902b430b 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -521,8 +521,10 @@ flowchart TD pkg_message_feedback --> pkg_storage_domain pkg_message_feedback --> pkg_typert_protocol pkg_commands --> pkg_agent + pkg_commands --> pkg_attachment pkg_commands --> pkg_brand pkg_commands --> pkg_invariants + pkg_commands --> pkg_llm pkg_commands --> pkg_scope pkg_commands --> pkg_session pkg_commands --> pkg_typert_protocol @@ -613,6 +615,7 @@ flowchart TD pkg_command_goal --> pkg_commands pkg_command_goal --> pkg_goal pkg_command_goal --> pkg_invariants + pkg_command_goal --> pkg_llm pkg_goal_round_driver --> pkg_agent pkg_goal_round_driver --> pkg_goal pkg_goal_round_driver --> pkg_invariants @@ -1494,7 +1497,7 @@ flowchart TD | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | -| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | @@ -1513,7 +1516,7 @@ flowchart TD | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | -| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 664e85e24b..6198edf4e0 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -523,8 +523,10 @@ flowchart TD pkg_message_feedback --> pkg_storage_domain pkg_message_feedback --> pkg_typert_protocol pkg_commands --> pkg_agent + pkg_commands --> pkg_attachment pkg_commands --> pkg_brand pkg_commands --> pkg_invariants + pkg_commands --> pkg_llm pkg_commands --> pkg_scope pkg_commands --> pkg_session pkg_commands --> pkg_typert_protocol @@ -615,6 +617,7 @@ flowchart TD pkg_command_goal --> pkg_commands pkg_command_goal --> pkg_goal pkg_command_goal --> pkg_invariants + pkg_command_goal --> pkg_llm pkg_goal_round_driver --> pkg_agent pkg_goal_round_driver --> pkg_goal pkg_goal_round_driver --> pkg_invariants @@ -1496,7 +1499,7 @@ flowchart TD | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | -| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | @@ -1515,7 +1518,7 @@ flowchart TD | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | -| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index f73e849acf..295a69a8a8 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.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 docs/persistence-catalog.md -persistence-catalog.md: cf796ca322027886b1a0b78d69ac1d3a98d9459f -persistence-catalog.zh.md: 5f82254813ef6fb0b3c9244a2bd03fb2177e5559 +persistence-catalog.md: b680bccf22f7840663e5268eb3feeb6b16f7fd42 +persistence-catalog.zh.md: 4b50582fa55dbdc672d8c45debe108b97a55f0e2 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index cf796ca322..b680bccf22 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -256,7 +256,7 @@ Source: [`packages/core/session/src/types.ts:273`](../packages/core/session/src/ } ``` -Source: [`packages/interaction/commands/src/types.ts:95`](../packages/interaction/commands/src/types.ts) +Source: [`packages/interaction/commands/src/types.ts:103`](../packages/interaction/commands/src/types.ts) @@ -276,7 +276,7 @@ Source: [`packages/interaction/commands/src/types.ts:95`](../packages/interactio 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource } ``` -Source: [`packages/interaction/commands/src/types.ts:88`](../packages/interaction/commands/src/types.ts) +Source: [`packages/interaction/commands/src/types.ts:96`](../packages/interaction/commands/src/types.ts) ### `compaction/*` @@ -527,7 +527,7 @@ Source: [`packages/interaction/permission-presets/src/index.ts:50`](../packages/ 'plan/mode': { active: boolean } ``` -Source: [`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:54`](../packages/plan/plan-mode/src/index.ts) ### `request/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 5f82254813..4b50582fa5 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -258,7 +258,7 @@ export type SessionEvent = { } ``` -来源:[`packages/interaction/commands/src/types.ts:95`](../packages/interaction/commands/src/types.ts) +来源:[`packages/interaction/commands/src/types.ts:103`](../packages/interaction/commands/src/types.ts) @@ -278,7 +278,7 @@ export type SessionEvent = { 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource } ``` -来源:[`packages/interaction/commands/src/types.ts:88`](../packages/interaction/commands/src/types.ts) +来源:[`packages/interaction/commands/src/types.ts:96`](../packages/interaction/commands/src/types.ts) ### `compaction/*` @@ -529,7 +529,7 @@ export type SessionEvent = { 'plan/mode': { active: boolean } ``` -来源:[`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts) +来源:[`packages/plan/plan-mode/src/index.ts:54`](../packages/plan/plan-mode/src/index.ts) ### `request/*` diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index 023bbafab0..6b488893b2 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.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 docs/subsystems/attachment.md -attachment.md: 21e60dbc40504f22229ef98a2dd112eda82fffdd -attachment.zh.md: 886e569b6db9f2a5b1dca39125785d8286e22c7a +attachment.md: 748d3feb47ff6bdf2ab6849f2509a3c495fcd87a +attachment.zh.md: eed28affb2c40726a088ddf2f0e17077d5a22df6 diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index 21e60dbc40..748d3feb47 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -52,6 +52,18 @@ The reference records intrinsic dimensions and encoded length so clients can lay ## Commit and verified-read payloads +```ts type-equiv +/** Base64-encoded image upload accompanying one wire request. */ +interface EncodedImageAttachment { + /** Declared media type, verified against the decoded bytes during admission. */ + mediaType: ImageMediaType + /** Canonical base64 encoding of the image bytes. */ + data: string + /** Optional display name; it is never interpreted as a path. */ + name?: string +} +``` + ```ts type-equiv /** Request to validate and durably commit one image. */ interface SaveImageAttachment { @@ -71,7 +83,7 @@ interface StoredImageAttachment { } ``` -`saveImage()` validates bytes and atomically commits one object before returning its reference. `validateImage()` runs the same admission checks without persisting anything; batch callers validate every member through it before saving any member, so validation rejection leaves no partial objects behind. `readImage()` accepts a reference from an authorized session path and returns bytes only after integrity verification. The service is deliberately retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to any one session's deletion. +`saveImage()` validates bytes and atomically commits one object before returning its reference. `validateImage()` runs the same admission checks without persisting anything; batch callers validate every member through it before saving any member, so validation rejection leaves no partial objects behind. `admitEncodedImages()` is the wire entry for base64 uploads: it enforces canonical base64, then delegates batch admission to `saveImages()`, which owns the count and aggregate-byte limits and the validate-all-before-save order. `readImage()` accepts a reference from an authorized session path and returns bytes only after integrity verification. The service is deliberately retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to any one session's deletion. @@ -123,5 +135,5 @@ abstract saveImage(input: SaveImageAttachment): Promise abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise ``` -Source: [`packages/attachment/attachment/src/index.ts:31`](../../packages/attachment/attachment/src/index.ts) +Source: [`packages/attachment/attachment/src/index.ts:33`](../../packages/attachment/attachment/src/index.ts) diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index 886e569b6d..eed28affb2 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -52,6 +52,18 @@ interface ImageAttachmentLimits { ## 提交与经校验读取的数据 +```ts type-equiv +/** Base64-encoded image upload accompanying one wire request. */ +interface EncodedImageAttachment { + /** Declared media type, verified against the decoded bytes during admission. */ + mediaType: ImageMediaType + /** Canonical base64 encoding of the image bytes. */ + data: string + /** Optional display name; it is never interpreted as a path. */ + name?: string +} +``` + ```ts type-equiv /** Request to validate and durably commit one image. */ interface SaveImageAttachment { @@ -71,7 +83,7 @@ interface StoredImageAttachment { } ``` -`saveImage()` 校验字节并以原子方式提交一个对象,之后才返回其引用。`validateImage()` 执行相同的准入检查,但不持久化任何内容;批量调用方会在保存任何成员前通过它校验所有成员,因此校验拒绝不会留下部分对象。`readImage()` 接受来自已授权会话路径的引用,只在完整性校验通过后返回字节。该服务刻意不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,而不是与任何一个会话的删除绑定。 +`saveImage()` 校验字节并以原子方式提交一个对象,之后才返回其引用。`validateImage()` 执行相同的准入检查,但不持久化任何内容;批量调用方会在保存任何成员前通过它校验所有成员,因此校验拒绝不会留下部分对象。`admitEncodedImages()` 是面向 base64 上传的 wire 入口:强制执行规范 base64,随后把批量准入委托给 `saveImages()`,由后者负责张数与聚合字节上限以及先全量校验再保存的顺序。`readImage()` 接受来自已授权会话路径的引用,只在完整性校验通过后返回字节。该服务刻意不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,而不是与任何一个会话的删除绑定。 @@ -123,5 +135,5 @@ abstract saveImage(input: SaveImageAttachment): Promise abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise ``` -Source: [`packages/attachment/attachment/src/index.ts:31`](../../packages/attachment/attachment/src/index.ts) +Source: [`packages/attachment/attachment/src/index.ts:33`](../../packages/attachment/attachment/src/index.ts) diff --git a/docs/subsystems/commands.i18n.yaml b/docs/subsystems/commands.i18n.yaml index e01ca50997..6a91f514ce 100644 --- a/docs/subsystems/commands.i18n.yaml +++ b/docs/subsystems/commands.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 docs/subsystems/commands.md -commands.md: a4589d875fafdda7404e8c2d54fb739a4e945990 -commands.zh.md: 460784442257cc081fb73646c51885a432efadb5 +commands.md: a9752915d4eae22d448b5480f2d50746fd02ec5f +commands.zh.md: 2fe3d71bafaf6653251a8ab53832dd4701152194 diff --git a/docs/subsystems/commands.md b/docs/subsystems/commands.md index a4589d875f..a9752915d4 100644 --- a/docs/subsystems/commands.md +++ b/docs/subsystems/commands.md @@ -8,13 +8,21 @@ Source: [`packages/interaction/commands/src/index.ts`](../../packages/interactio ## Input metadata -The service exposes one optional unstructured-input hint. Command availability follows plugin composition: every adapter consuming the registry sees every effective definition. +The service exposes one optional unstructured-input descriptor: a hint plus an image-acceptance flag. Command availability follows plugin composition: every adapter consuming the registry sees every effective definition. ```ts type-equiv /** Immutable metadata for a command's optional unstructured input. */ interface CommandInputDescriptor { /** Placeholder shown before the user supplies free-form input. */ readonly hint: string + /** + * Whether composer image attachments may accompany an invocation. Absent or + * false = the executor rejects an invocation carrying images and capable + * composers refuse the submission before dispatch. A declaring command's + * handler receives the admitted durable blocks and owns every further + * grammar decision, including rejecting sub-commands that cannot use them. + */ + readonly images?: boolean } ``` @@ -55,6 +63,14 @@ interface CommandInvocation { readonly agent: Agent /** Exact text following the registered command name, including separator whitespace. */ readonly rawInput: string + /** + * Durably admitted image blocks accompanying this invocation, in submission + * order; empty unless the definition declares `input.images`. The handler + * owns their model-visible use — the registry never schedules them itself — + * and a handler whose grammar cannot use them in this invocation returns an + * error so the dispatching composer retains the originals. + */ + readonly attachments: readonly ImageBlock[] /** Cancellation signal owned by the dispatching UI request. */ readonly signal: AbortSignal } @@ -150,18 +166,25 @@ find(agent: Agent, name: string): CommandDefinition | undefined * handler-failure path is contained so the handler's own error stays the * reported failure. * + * Image admission is enforced here, not in the composer: images sent to a + * command that does not declare `input.images`, an absent attachment store, + * and an exceeded attachment limit each settle as an error result before + * the handler runs, and a rejected batch publishes no durable object. + * * @param agent - exact receiving agent. * @param line - complete slash-command line. + * @param images - base64-encoded composer images accompanying the line, in + * submission order; empty for a plain invocation. * @param signal - cancellation signal owned by the UI request. * @returns the settled execution (result + lifecycle pairing id), or * `undefined` when syntax or name does not resolve. */ -@Remote async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise +@Remote async execute( agent: Agent, line: string, images: readonly EncodedImageAttachment[], signal: AbortSignal, ): Promise ``` -Types: [Agent](core.md) +Types: [Agent](core.md) · [EncodedImageAttachment](attachment.md) -Source: [`packages/interaction/commands/src/index.ts:225`](../../packages/interaction/commands/src/index.ts) +Source: [`packages/interaction/commands/src/index.ts:250`](../../packages/interaction/commands/src/index.ts) @@ -183,5 +206,5 @@ A command was registered or unregistered. This is an unfiltered registry notific 'commands/change'(): void ``` -Source: [`packages/interaction/commands/src/types.ts:72`](../../packages/interaction/commands/src/types.ts) +Source: [`packages/interaction/commands/src/types.ts:80`](../../packages/interaction/commands/src/types.ts) diff --git a/docs/subsystems/commands.zh.md b/docs/subsystems/commands.zh.md index 4607844422..2fe3d71baf 100644 --- a/docs/subsystems/commands.zh.md +++ b/docs/subsystems/commands.zh.md @@ -8,13 +8,21 @@ ## 输入元数据 -该服务公开一个可选的非结构化输入提示。命令的可用性由插件组合决定:每个消费注册表的适配器都会看到全部生效定义。 +该服务公开一个可选的非结构化输入描述符:提示文本加图片接受标志。命令的可用性由插件组合决定:每个消费注册表的适配器都会看到全部生效定义。 ```ts type-equiv /** Immutable metadata for a command's optional unstructured input. */ interface CommandInputDescriptor { /** Placeholder shown before the user supplies free-form input. */ readonly hint: string + /** + * Whether composer image attachments may accompany an invocation. Absent or + * false = the executor rejects an invocation carrying images and capable + * composers refuse the submission before dispatch. A declaring command's + * handler receives the admitted durable blocks and owns every further + * grammar decision, including rejecting sub-commands that cannot use them. + */ + readonly images?: boolean } ``` @@ -55,6 +63,14 @@ interface CommandInvocation { readonly agent: Agent /** Exact text following the registered command name, including separator whitespace. */ readonly rawInput: string + /** + * Durably admitted image blocks accompanying this invocation, in submission + * order; empty unless the definition declares `input.images`. The handler + * owns their model-visible use — the registry never schedules them itself — + * and a handler whose grammar cannot use them in this invocation returns an + * error so the dispatching composer retains the originals. + */ + readonly attachments: readonly ImageBlock[] /** Cancellation signal owned by the dispatching UI request. */ readonly signal: AbortSignal } @@ -150,18 +166,25 @@ find(agent: Agent, name: string): CommandDefinition | undefined * handler-failure path is contained so the handler's own error stays the * reported failure. * + * Image admission is enforced here, not in the composer: images sent to a + * command that does not declare `input.images`, an absent attachment store, + * and an exceeded attachment limit each settle as an error result before + * the handler runs, and a rejected batch publishes no durable object. + * * @param agent - exact receiving agent. * @param line - complete slash-command line. + * @param images - base64-encoded composer images accompanying the line, in + * submission order; empty for a plain invocation. * @param signal - cancellation signal owned by the UI request. * @returns the settled execution (result + lifecycle pairing id), or * `undefined` when syntax or name does not resolve. */ -@Remote async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise +@Remote async execute( agent: Agent, line: string, images: readonly EncodedImageAttachment[], signal: AbortSignal, ): Promise ``` -Types: [Agent](core.md) +Types: [Agent](core.md) · [EncodedImageAttachment](attachment.md) -Source: [`packages/interaction/commands/src/index.ts:225`](../../packages/interaction/commands/src/index.ts) +Source: [`packages/interaction/commands/src/index.ts:250`](../../packages/interaction/commands/src/index.ts) @@ -183,5 +206,5 @@ A command was registered or unregistered. This is an unfiltered registry notific 'commands/change'(): void ``` -Source: [`packages/interaction/commands/src/types.ts:72`](../../packages/interaction/commands/src/types.ts) +Source: [`packages/interaction/commands/src/types.ts:80`](../../packages/interaction/commands/src/types.ts) diff --git a/docs/subsystems/plan.i18n.yaml b/docs/subsystems/plan.i18n.yaml index 857839b0c5..4623b14448 100644 --- a/docs/subsystems/plan.i18n.yaml +++ b/docs/subsystems/plan.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 docs/subsystems/plan.md -plan.md: 4e6eb98e7c7cce295feeed0150984934f1a853e5 -plan.zh.md: f8236e6cbeca841bdab630aa831e844cc68179a0 +plan.md: 1f6863a24aa56773430be904e5a27c27384c9bff +plan.zh.md: 056bce946b608876ac958f2d33d871e9622c7187 diff --git a/docs/subsystems/plan.md b/docs/subsystems/plan.md index 4e6eb98e7c..1f6863a24a 100644 --- a/docs/subsystems/plan.md +++ b/docs/subsystems/plan.md @@ -83,5 +83,5 @@ set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop Types: [Agent](core.md) -Source: [`packages/plan/plan-mode/src/index.ts:184`](../../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:188`](../../packages/plan/plan-mode/src/index.ts) diff --git a/docs/subsystems/plan.zh.md b/docs/subsystems/plan.zh.md index f8236e6cbe..056bce946b 100644 --- a/docs/subsystems/plan.zh.md +++ b/docs/subsystems/plan.zh.md @@ -83,5 +83,5 @@ set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop Types: [Agent](core.md) -Source: [`packages/plan/plan-mode/src/index.ts:184`](../../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:188`](../../packages/plan/plan-mode/src/index.ts) diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index 7075b0fb50..fd02d455a4 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/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/attachment/attachment/README.md -README.md: 4fe608552492c33d2bd9acddce51ea1cf20acae4 -README.zh.md: a3093fc9dd1f926cb1c54831c6302eb7bbca25c5 +README.md: 19232bd4bb86ed33e56fcdca93999967822422ab +README.zh.md: e5e7aab7c1af30b2b101bdcd218044cd1095ae0d diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 4fe6085524..19232bd4bb 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -6,6 +6,8 @@ The durable attachment seam. `ctx.attachments` validates and durably commits imm Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, validates every member before writing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata. Callers may cancel `readImage`; implementations observe cancellation around backend and verification work and preserve it instead of translating it into a storage failure. +`admitEncodedImages(attachments, images)` is the shared wire entry used by every RPC endpoint that accepts browser uploads (the session prompt endpoint and the command executor): it enforces canonical base64 on every member, then delegates batch admission — limits, validation, ordered commit — to `saveImages`. The base64 upload form is `EncodedImageAttachment`, exported from `@deepseek-ai/dsh-attachment/types` so wire contracts can reference it. + ## Model Experience Indirectly, through the role-neutral core `ImageBlock` and provider adapters that resolve its durable reference. diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index a3093fc9dd..e5e7aab7c1 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -6,6 +6,8 @@ 未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,先校验全部成员,再按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。调用方可以取消 `readImage`;实现会在后端读取与校验工作的边界观察取消,并保留取消语义,而不会将其转换为存储失败。 +`admitEncodedImages(attachments, images)` 是每个接受浏览器上传的 RPC 端点(会话 prompt 端点与命令执行器)共用的 wire 入口:它对每个成员强制执行规范 base64,随后把批量准入——限额、校验、有序提交——委托给 `saveImages`。base64 上传形式为 `EncodedImageAttachment`,从 `@deepseek-ai/dsh-attachment/types` 导出,供 wire 契约引用。 + ## 模型体验 该包通过角色无关的核心 `ImageBlock`,以及解析其持久引用的提供方适配器,间接影响模型。 diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index f1ee4f97ed..87764ef5e2 100644 --- a/packages/attachment/attachment/package.json +++ b/packages/attachment/attachment/package.json @@ -16,10 +16,11 @@ "exports": { ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./types": { "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, - "files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.d.ts"], + "files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.js", "lib/types/**/*.d.ts"], "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", diff --git a/packages/attachment/attachment/src/admission.ts b/packages/attachment/attachment/src/admission.ts new file mode 100644 index 0000000000..d31bc6d831 --- /dev/null +++ b/packages/attachment/attachment/src/admission.ts @@ -0,0 +1,41 @@ +/** Wire-form admission of base64-encoded image uploads. @module @deepseek-ai/dsh-attachment/admission */ + +import { Buffer } from 'node:buffer' +import { AttachmentError } from './error.ts' +import type { AttachmentStore } from './index.ts' +import type { EncodedImageAttachment, ImageAttachmentRef, SaveImageAttachment } from './types.ts' + +/** Decode one upload payload while rejecting non-canonical base64 forms. */ +function decodeBase64(data: string): Uint8Array { + const decoded = Buffer.from(data, 'base64') + if (data.length === 0 || decoded.toString('base64') !== data) { + throw new AttachmentError('Image upload is not canonical base64.', 'INVALID_IMAGE_BASE64') + } + return new Uint8Array(decoded) +} + +/** Store input for one decoded upload. */ +function saveInput(image: EncodedImageAttachment): SaveImageAttachment { + return { + data: decodeBase64(image.data), + mediaType: image.mediaType, + ...image.name === undefined ? {} : { name: image.name }, + } +} + +/** + * Admit one wire image batch: enforce canonical base64 on every member, then + * delegate batch admission — count and aggregate-byte limits, media-type and + * per-image validation, ordered commit — to {@link AttachmentStore.saveImages}. + * The shared entry for every RPC endpoint accepting browser uploads. + * @param attachments - the deployment attachment store owning batch policy. + * @param images - base64-encoded uploads in caller order. + * @returns durable references in the same order as `images`. + * @throws AttachmentError on a non-canonical payload or a refused batch. + */ +export async function admitEncodedImages( + attachments: AttachmentStore, + images: readonly EncodedImageAttachment[], +): Promise { + return attachments.saveImages(images.map(saveInput)) +} diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 11283cfd4b..1480751c15 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -12,8 +12,10 @@ import type { export { AttachmentId } from './brand.ts' export { AttachmentError, isImageAdmissionError } from './error.ts' export type { AttachmentErrorCode, ImageAdmissionErrorCode } from './error.ts' +export { admitEncodedImages } from './admission.ts' export type { AttachmentId as AttachmentIdType, + EncodedImageAttachment, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType, diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index 62ff598fee..7c29231172 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -34,6 +34,16 @@ export interface ImageAttachmentLimits { mediaTypes: readonly ImageMediaType[] } +/** Base64-encoded image upload accompanying one wire request. */ +export interface EncodedImageAttachment { + /** Declared media type, verified against the decoded bytes during admission. */ + mediaType: ImageMediaType + /** Canonical base64 encoding of the image bytes. */ + data: string + /** Optional display name; it is never interpreted as a path. */ + name?: string +} + /** Request to validate and durably commit one image. */ export interface SaveImageAttachment { data: Uint8Array diff --git a/packages/attachment/attachment/tests/admission.spec.ts b/packages/attachment/attachment/tests/admission.spec.ts new file mode 100644 index 0000000000..4c929b6d5c --- /dev/null +++ b/packages/attachment/attachment/tests/admission.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' +import { admitEncodedImages } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentRef, SaveImageAttachment } from '@deepseek-ai/dsh-attachment/types' + +const PNG = 'AAAA' // canonical base64, 3 bytes + +/** Delegation double: records the exact saveImages batch and answers ordered refs. */ +function storeOf() { + const store = { + saveImages: vi.fn((inputs: readonly SaveImageAttachment[]) => Promise.resolve(inputs.map((input, index): ImageAttachmentRef => ({ + attachmentId: `att-${index + 1}` as ImageAttachmentRef['attachmentId'], + mediaType: input.mediaType, + bytes: input.data.byteLength, + width: 1, + height: 1, + ...input.name === undefined ? {} : { name: input.name }, + })))), + } + return { store: store as unknown as AttachmentStore, mocks: store } +} + +describe('admitEncodedImages', () => { + it('decodes every member and delegates one ordered batch to saveImages', async () => { + const { store, mocks } = storeOf() + const refs = await admitEncodedImages(store, [ + { mediaType: 'image/png', data: PNG, name: 'first.png' }, + { mediaType: 'image/jpeg', data: PNG, name: 'second.jpg' }, + ]) + expect(mocks.saveImages).toHaveBeenCalledTimes(1) + const batch = mocks.saveImages.mock.calls[0]?.[0] as readonly SaveImageAttachment[] + expect(batch.map(input => [input.name, input.mediaType, input.data.byteLength])) + .toEqual([['first.png', 'image/png', 3], ['second.jpg', 'image/jpeg', 3]]) + expect(refs.map(ref => ref.attachmentId)).toEqual(['att-1', 'att-2']) + }) + + it('omits the name from store inputs when the upload has none', async () => { + const { store, mocks } = storeOf() + const refs = await admitEncodedImages(store, [{ mediaType: 'image/webp', data: PNG }]) + const batch = mocks.saveImages.mock.calls[0]?.[0] as readonly SaveImageAttachment[] + expect('name' in (batch[0] as object)).toBe(false) + expect(refs[0]?.name).toBeUndefined() + }) + + it('delegates an empty batch unchanged', async () => { + const { store, mocks } = storeOf() + await expect(admitEncodedImages(store, [])).resolves.toEqual([]) + expect(mocks.saveImages).toHaveBeenCalledWith([]) + }) + + it('rejects non-canonical and empty base64 payloads before any store call', async () => { + const { store, mocks } = storeOf() + for (const data of ['', 'AAA', '!!!!']) { + await expect(admitEncodedImages(store, [{ mediaType: 'image/png', data }])) + .rejects.toMatchObject({ name: 'AttachmentError', code: 'INVALID_IMAGE_BASE64' }) + } + expect(mocks.saveImages).not.toHaveBeenCalled() + }) + + it('propagates the store batch rejection unchanged', async () => { + const { store, mocks } = storeOf() + const refused = Object.assign(new Error('Image batch exceeds the configured image-count limit.'), { code: 'TOO_MANY_IMAGES' }) + mocks.saveImages.mockRejectedValueOnce(refused) + await expect(admitEncodedImages(store, [{ mediaType: 'image/png', data: PNG }])).rejects.toBe(refused) + }) +}) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 95de638d6a..1064e82e37 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -745,26 +745,34 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi } /** - * Fixture parallel of the plan unit's double-event fold: `command/run` - * 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). + * Fixture parallel of the plan unit's lifecycle fold. The paired + * `command/done` retains successful plan selections and drops failures; + * `plan/mode` commits one. `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 let wanted: boolean | null = null + let running: { commandId: unknown; wanted: boolean } | null = null for (const event of log) { const item = event as unknown as { type: string; data?: Record } if (item.type === 'command/run' && item.data?.['name'] === 'plan') { const args = item.data['args'] if (typeof args !== 'string') continue - wanted = args.trim() !== 'off' + running = { commandId: item.data['commandId'], wanted: args.trim() !== 'off' } + } else if (item.type === 'command/done' + && item.data !== undefined + && running !== null + && item.data['commandId'] === running.commandId) { + wanted = item.data['kind'] === 'success' && running.wanted !== active ? running.wanted : null + running = null } else if (item.type === 'plan/mode') { active = item.data?.['active'] === true wanted = null } } - return { active, pending: wanted !== null && wanted !== active, wanted } + const selected = running?.wanted ?? wanted + return { active, pending: selected !== null && selected !== active, wanted: selected } } /** The plan projection's wire view over the full log. */ @@ -1737,13 +1745,13 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { value: [ { name: 'compact', description: 'fixture:压缩当前会话上下文' }, { name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } }, - { name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '' } }, + { name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '', images: true } }, { name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '' } }, - { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]' } }, + { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', images: true } }, ], } }, - execute(id: SessionId, line: string): RpcResult { + execute(id: SessionId, line: string, images: readonly unknown[] = []): RpcResult { const missing = requireGoalSession(id) if (missing !== undefined) return missing // Structured split mirroring the Host parser: name + verbatim rawInput @@ -1751,6 +1759,29 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { const match = /^\/(\S+)((?:\s.*)?)$/.exec(line.trim()) const name = match?.[1] const args = match?.[2] ?? '' + // Mirror the Host image policy AFTER command resolution, matching the + // executor's order (an unknown name answers undefined and logs no + // lifecycle): the declaration rejection covers every known command + // without `input.images`, and the two producer grammar rejections cover + // the declaring commands' control-only lines. The fixture stores no + // bytes, so an accepted batch is acknowledged and dropped. + const known = ['permission', 'goal', 'compact', 'echo', 'plan'] + if (images.length > 0 && name !== undefined && known.includes(name)) { + const rejection = name !== 'goal' && name !== 'plan' + ? `/${name} does not accept image attachments` + : name === 'goal' && args.trim() === '' + ? 'Image attachments only accompany a goal objective: /goal or /goal edit .' + : name === 'plan' && args.trim() === 'off' + ? 'Image attachments cannot accompany /plan off.' + : undefined + if (rejection !== undefined) { + const commandId = `fx-cmd-${logOf(id).length}` as CommandId + append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }) + const result: CommandResult = { kind: 'error', text: rejection } + append(id, { type: 'command/done', data: { commandId, ...result } }) + return { ok: true, value: { commandId, result } } + } + } if (name === 'permission') { const preset = args.trim() const commandId = `fx-cmd-${logOf(id).length}` as CommandId @@ -3015,6 +3046,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { args: { agentId: SessionId line?: string + images?: readonly unknown[] ref?: { id: string; revision: number } request?: { objective?: string; maxGoalRounds?: number } } @@ -3022,7 +3054,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { const sessionId = args.agentId switch (endpoint) { case 'commands/list': return Promise.resolve(commandRemotes.list(sessionId)) - case 'commands/execute': return Promise.resolve(commandRemotes.execute(sessionId, args.line as string)) + case 'commands/execute': return Promise.resolve(commandRemotes.execute(sessionId, args.line as string, args.images ?? [])) case 'goals/create': return Promise.resolve(goalRemotes.create(sessionId, { objective: args.request?.objective as string, ...args.request?.maxGoalRounds === undefined ? {} : { maxGoalRounds: args.request.maxGoalRounds }, diff --git a/packages/client/connection/tests/fixture-commands.client.spec.ts b/packages/client/connection/tests/fixture-commands.client.spec.ts index 909d0baf46..62118062b5 100644 --- a/packages/client/connection/tests/fixture-commands.client.spec.ts +++ b/packages/client/connection/tests/fixture-commands.client.spec.ts @@ -28,13 +28,15 @@ const req =

(payload: P): RpcRequest

=> ({ rpcId: RpcId(`t-${reqCount++}`) describe('createFixtureApi commands/skills', () => { it('serves the addressed session catalog', async () => { const { rpc } = createFixtureFaces() - const commands = await callRemote<{ name: string; input?: { hint: string } }[]>( + const commands = await callRemote<{ name: string; input?: { hint: string; images?: boolean } }[]>( rpc, 'commands/list', { agentId: sid('fx-alpha') }) expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal', 'permission', 'plan']) // input hint rides only the commands declaring it. const echo = commands.find(c => c.name === 'echo') expect(echo?.input?.hint).toBeTruthy() expect(commands.find(c => c.name === 'compact')?.input).toBeUndefined() + // Image acceptance is declared per descriptor; only goal and plan carry it. + expect(commands.filter(c => c.input?.images === true).map(c => c.name)).toEqual(['goal', 'plan']) }) it('rejects a catalog request for an unknown session', async () => { @@ -80,6 +82,70 @@ describe('createFixtureApi commands/skills', () => { expect(missing).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) }) + it('refuses an image-carrying execute for a non-declaring command with a logged error pair', async () => { + const { api, rpc } = createFixtureFaces() + const frames: unknown[] = [] + const abort = new AbortController() + const stream = api.events.mux(req({}), abort.signal) + const pump = (async () => { + for await (const frame of stream) { + frames.push(frame.payload) + if (frames.filter(f => (f as { type: string }).type === 'session/event').length >= 2) abort.abort() + } + })() + const png = { mediaType: 'image/png', data: 'AA==' } + const refused = await callRemote<{ commandId: string; result: { kind: string; text?: string } } | undefined>( + rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/echo hi', images: [png] }) + expect(refused?.commandId).toBeTruthy() + expect(refused?.result).toEqual({ kind: 'error', text: '/echo does not accept image attachments' }) + await pump + const events = frames + .filter((f): f is { type: string; event: { type: string; data: Record } } => (f as { type: string }).type === 'session/event') + .map(f => f.event) + expect(events).toMatchObject([ + { type: 'command/run', data: { name: 'echo', args: ' hi', source: { kind: 'user' } } }, + { type: 'command/done', data: { kind: 'error', text: '/echo does not accept image attachments' } }, + ]) + }) + + it('a declaring command accepts an image-carrying execute', async () => { + const { rpc } = createFixtureFaces() + const png = { mediaType: 'image/png', data: 'AA==' } + const accepted = await callRemote<{ result: { kind: string } } | undefined>( + rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/goal ship it', images: [png] }) + expect(accepted?.result.kind).toBe('success') + const planMessage = await callRemote<{ result: { kind: string } } | undefined>( + rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/plan sketch the layout', images: [png] }) + expect(planMessage?.result.kind).toBe('success') + const imageOnlyPlan = await callRemote<{ result: { kind: string } } | undefined>( + rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/plan', images: [png] }) + expect(imageOnlyPlan?.result.kind).toBe('success') + }) + + it('mirrors the producer grammar rejections for control-only declaring lines', async () => { + const { rpc } = createFixtureFaces() + const png = { mediaType: 'image/png', data: 'AA==' } + const bareGoal = await callRemote<{ result: { kind: string; text?: string } } | undefined>( + rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/goal', images: [png] }) + expect(bareGoal?.result).toEqual({ + kind: 'error', + text: 'Image attachments only accompany a goal objective: /goal or /goal edit .', + }) + const refused = await callRemote<{ result: { kind: string; text?: string } } | undefined>( + rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/plan off', images: [png] }) + expect(refused?.result).toEqual({ + kind: 'error', + text: 'Image attachments cannot accompany /plan off.', + }) + }) + + it('answers no execution for an unknown name even when images accompany it', async () => { + const { rpc } = createFixtureFaces() + const png = { mediaType: 'image/png', data: 'AA==' } + expect(await callRemote(rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/nope', images: [png] })) + .toBeUndefined() + }) + it('answers no execution for unknown names and non-command lines', async () => { const { rpc } = createFixtureFaces() for (const line of ['/nope', 'plain text', '/']) { diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 3e5ec5a811..f38e3c757b 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -356,7 +356,7 @@ export class Session implements SessionFace { * @returns the admission result, or the error branch on transport failure. */ async command(line: string): Promise> { - const result = await this.remote.commands.execute(this.sessionId, line) + const result = await this.remote.commands.execute(this.sessionId, line, []) if (!result.ok) return result return { ok: true, value: { matched: result.value !== undefined } } } diff --git a/packages/client/ui-commands/README.i18n.yaml b/packages/client/ui-commands/README.i18n.yaml index 1c95c39494..4fe118592e 100644 --- a/packages/client/ui-commands/README.i18n.yaml +++ b/packages/client/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/client/ui-commands/README.md -README.md: 67110ffd8c1ad11e56ca9293a9064c66dd08c81d -README.zh.md: 40fe21850dd289d2a5c91bd88d4f22c087731b80 +README.md: 2140495a44110d5e4b33e4cc8f539959752ac185 +README.zh.md: afa47cd18505b9afbd3e867d131e9796db598895 diff --git a/packages/client/ui-commands/README.md b/packages/client/ui-commands/README.md index 67110ffd8c..2140495a44 100644 --- a/packages/client/ui-commands/README.md +++ b/packages/client/ui-commands/README.md @@ -8,6 +8,8 @@ Client command API (`ctx.commandUi`): the session-keyed command-directory cache, `CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the forwarded `commands/change` owner event (old snapshots serve while the repull flies) and by forwarded `agent-preset/selected` for that one session (recomposing an agent registers nothing, so the registry-wide signal never fires for it), hard-invalidated by `connection/reset`, and epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt. +`matchEnter` also enforces the submission envelope: when the composer submits with image attachments, only a host command declaring `input.images` proceeds (its claim carries `images: true` and its submit forwards the serialized payloads to `command.execute`); every other command route — contribution popup, decorated popup, non-declaring claim, bare detached execute — throws the localized `notice.imagesUnsupported` refusal, which the input machine publishes as one error notice and the composer renders as a transient Toast banner with the draft and images retained. An image-carrying submit whose host handler answers an error result maps to an error outcome so the composer keeps the images; imageless submits keep the plain success mapping because the durable flow node owns the outcome rendering. + After `command.execute` returns a matched command result, this browser emits local `command/executed(sessionId, name, result)`. Other clients receive the durable command nodes through the Host event stream but never this acknowledgment, so a browser-only side effect can select successful results from the client that submitted the command without treating Session replay as an action request. Listener failures are logged and contained one by one; they cannot change the already-admitted command result or prevent later listeners from running. Menu queries fuzzy-match ordered, case-insensitive subsequences of command names. Prefixes rank first; separator boundaries, adjacent characters, and shorter gaps rank the remaining matches, with directory and contribution order breaking ties. This affects discovery only: space and Enter still require an exact command name. Rationale: [Web slash-command fuzzy discovery](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md). diff --git a/packages/client/ui-commands/README.zh.md b/packages/client/ui-commands/README.zh.md index 40fe21850d..afa47cd185 100644 --- a/packages/client/ui-commands/README.zh.md +++ b/packages/client/ui-commands/README.zh.md @@ -8,6 +8,8 @@ `CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent,若预热它,就会仅因查看持久化历史而激活子代理。缓存项由转发的 owner 事件 `commands/change` 软失效(重拉在途期间旧快照继续服务),也由转发的 `agent-preset/selected` 对该会话单独软失效(重组 agent 不产生任何注册,注册表级信号不会为它触发),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。 +`matchEnter` 还强制执行提交信封:composer 携带图片附件提交时,只有声明了 `input.images` 的宿主命令继续(其 claim 携带 `images: true`,其 submit 把序列化载荷转交 `command.execute`);其余每条命令路径——contribution 弹窗、decoration 弹窗、未声明的 claim、bare 分离执行——都会抛出本地化的 `notice.imagesUnsupported` 拒绝,输入状态机发布一条错误通知,composer 以瞬态 Toast 横幅呈现它,草稿与图片原样保留。带图提交若宿主处理器返回错误结果,则映射为错误 outcome,composer 保留图片;不带图的提交维持原有的一律成功映射,因为结果呈现由持久化 flow 节点负责。 + `command.execute` 返回已匹配的命令结果后,当前浏览器会发布本地 `command/executed(sessionId, name, result)`。其他客户端只会通过 Host 事件流收到持久命令节点,不会收到这条确认,因此浏览器专属副作用可以筛选由实际提交命令的客户端收到的成功结果,而不会把 Session 回放当成操作请求。监听器失败会逐项记录并隔离,不会改变已经准入的命令结果,也不会阻止后续监听器运行。 菜单查询会按顺序且不区分大小写地模糊匹配命令名的子序列。前缀排名最高;其余匹配项按分隔符边界优先、相邻字符优先、间隔越短越优先的规则排序,若仍同分,则以目录顺序和贡献项顺序打破平局。此行为只影响命令发现:space 和 Enter 仍要求命令名精确匹配。原理:[Web 斜杠命令模糊发现](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)。 diff --git a/packages/client/ui-commands/src/client/locales.ts b/packages/client/ui-commands/src/client/locales.ts index 63c5862cf2..1d04fe7eb7 100644 --- a/packages/client/ui-commands/src/client/locales.ts +++ b/packages/client/ui-commands/src/client/locales.ts @@ -9,6 +9,7 @@ export const zh = { 'status.empty': '无选项', 'overlay.aria': '/{command} 选项', 'listbox.aria': '/{command} 匹配项', + 'notice.imagesUnsupported': '/{command} 不接受图片附件,请先移除图片', } satisfies Record /** The command namespace key union. */ @@ -23,4 +24,5 @@ export const en = { 'status.empty': 'No options', 'overlay.aria': '/{command} options', 'listbox.aria': '/{command} matches', + 'notice.imagesUnsupported': '/{command} does not accept image attachments; remove them first', } satisfies Record diff --git a/packages/client/ui-commands/src/client/service.ts b/packages/client/ui-commands/src/client/service.ts index f9253c80d5..5db2033e29 100644 --- a/packages/client/ui-commands/src/client/service.ts +++ b/packages/client/ui-commands/src/client/service.ts @@ -14,9 +14,10 @@ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { CommandResult } from '@deepseek-ai/dsh-commands/types' import type { ClientContext, ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' import type { CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, InputTriggerCandidate, InputTriggerPick, - SubmitOutcome, + SubmitEnvelope, SubmitImageAttachment, SubmitOutcome, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { CommandContribution, CommandDecoration, CommandUiContract } from './contract.ts' import type { CommandDescriptor } from './directory.ts' @@ -122,6 +123,8 @@ export class CommandUiRuntime extends Service implements CommandUiContract { private readonly directory: CommandDirectory private readonly live: LiveState = { contributions: new Map(), decorations: new Map(), popups: new Map() } + /** `command`-namespace translator (composer refusal notices). */ + private readonly t: TranslateNS<'command'> /** * @param ctx - owning root context (plugin fiber; the service registers @@ -129,6 +132,9 @@ export class CommandUiRuntime extends Service implements CommandUiContract { */ constructor(ctx: Context) { super(ctx, 'commandUi') + const locale = ctx.get('locale') + if (locale === undefined) throw new Error('ui-commands: locale service unavailable') + this.t = locale.bind('command') this.directory = new CommandDirectory(async (sessionId) => { if (this.sessions().subagentAddress(sessionId) !== undefined) return [] const result = await ctx.remote.commands.list(sessionId) @@ -143,7 +149,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract { candidates: (session, req) => this.candidates(session, req), onPick: pick => this.dispatch(pick), matchSpace: (session, token) => this.matchSpace(session, token), - matchEnter: (session, line, signal) => this.matchEnter(session, line, signal), + matchEnter: (session, line, signal, envelope) => this.matchEnter(session, line, signal, envelope), warm: (session) => { this.directory.warm(session.sessionId) }, }), 'command: slash source') ctx.remote.$on('commands/change', () => { this.directory.invalidateAll() }) @@ -302,8 +308,19 @@ export class CommandUiRuntime extends Service implements CommandUiContract { * warmup failure rejects — never a silent downgrade). Contributions and * bare host commands act on the bare token only; leadingInput claims * args-tolerant. + * + * Envelope policy: an enter submission carrying images resolves only + * through a command declaring image acceptance. Every other command route — + * popup, non-accepting claim, bare detached execute — throws the refusal + * so the machine surfaces one composer notice and the draft and images + * stay in place; nothing executes and nothing is dropped. */ - private async matchEnter(session: ClientSessionContext, line: string, signal: AbortSignal): Promise { + private async matchEnter( + session: ClientSessionContext, + line: string, + signal: AbortSignal, + envelope: SubmitEnvelope, + ): Promise { const trimmed = line.trim() if (!trimmed.startsWith('/')) return undefined const ws = trimmed.search(/\s/) @@ -311,9 +328,13 @@ export class CommandUiRuntime extends Service implements CommandUiContract { const bare = ws === -1 const name = token.slice(1) if (name === '') return undefined + const refuseImages = (): never => { + throw new Error(this.t('notice.imagesUnsupported', { command: name })) + } const contribution = this.live.contributions.get(name) if (contribution !== undefined && contribution.available(session)) { if (!bare) return undefined + if (envelope.images > 0) refuseImages() this.openPopup(name, contribution.ui, session, { via: 'enter', token }) return 'handled' } @@ -325,12 +346,17 @@ export class CommandUiRuntime extends Service implements CommandUiContract { if (bare) { const decoration = this.live.decorations.get(name) if (decoration !== undefined && decoration.available(session)) { + if (envelope.images > 0) refuseImages() this.openPopup(name, decoration.ui, session, { via: 'enter', token }) return 'handled' } } - if (desc.input !== undefined) return { claim: this.leadingClaim(desc, session) } + if (desc.input !== undefined) { + if (envelope.images > 0 && desc.input.images !== true) refuseImages() + return { claim: this.leadingClaim(desc, session) } + } if (!bare) return undefined + if (envelope.images > 0) refuseImages() this.consumeVia(session.sessionId, { via: 'enter', token }) this.runDetached(desc, session, trimmed) return 'handled' @@ -354,7 +380,8 @@ export class CommandUiRuntime extends Service implements CommandUiContract { return { token, ...(desc.input !== undefined ? { hint: desc.input.hint } : {}), - submit: (args, _actx) => this.execute(session, token + args), + ...(desc.input?.images === true ? { images: true } : {}), + submit: (args, _actx, images) => this.execute(session, token + args, images), } } @@ -365,16 +392,24 @@ export class CommandUiRuntime extends Service implements CommandUiContract { * plain success regardless of its handler outcome, because the host * executor durably logged the lifecycle (`command/run`/`command/done`) and * the outcome renders as a persistent flow node — the composer never - * echoes it. Transport failures throw. + * echoes it. A handler error result reports an error outcome so the + * composer keeps the submission (draft and images) for correction. + * Transport failures throw. */ private async execute( session: ClientSessionContext, line: string, + images: readonly SubmitImageAttachment[] = [], ): Promise { - const result = await this.ctx.remote.commands.execute(session.sessionId, line) + const result = await this.ctx.remote.commands.execute(session.sessionId, line, images) if (!result.ok) throw new Error(`command.execute failed: ${result.error.code}: ${result.error.message}`) if (result.value === undefined) return { kind: 'error', text: `unknown or malformed command: ${line}` } this.notifyExecuted(session.sessionId, submittedCommandName(line), result.value.result) + // An image-carrying submission consumed its images only on handler + // success; an error outcome keeps draft and images in the composer. + if (images.length > 0 && result.value.result.kind === 'error') { + return { kind: 'error', text: result.value.result.text } + } return { kind: 'success' } } diff --git a/packages/client/ui-commands/tests/service.client.spec.ts b/packages/client/ui-commands/tests/service.client.spec.ts index 08fa1de833..1d3bc796f4 100644 --- a/packages/client/ui-commands/tests/service.client.spec.ts +++ b/packages/client/ui-commands/tests/service.client.spec.ts @@ -12,7 +12,7 @@ import { describe, expect, it, vi } from 'vitest' import type { CommandResult } from '@deepseek-ai/dsh-commands/types' import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' -import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource, SubmitImageAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts' import type { CommandDescriptor } from '../src/client/directory.ts' import { CommandUiRuntime } from '../src/client/service.ts' @@ -32,7 +32,7 @@ const S2_CMDS: CommandDescriptor[] = [ { name: 'attach', description: 'scoped shadow', input: { hint: 'path' } }, ] -type ExecuteValue = { matched: boolean; commandId?: string } +type ExecuteValue = { matched: boolean; commandId?: string; result?: CommandResult } interface BenchOptions { /** Scripted catalog per list payload; default serves the fixed catalogs by session. */ @@ -67,7 +67,7 @@ async function bench(opts: BenchOptions = {}) { const ctx = new Context() const registered = new Map() const listCalls: Array<{ sessionId: SessionId }> = [] - const executeCalls: Array<{ sessionId: SessionId; line: string }> = [] + const executeCalls: Array<{ sessionId: SessionId; line: string; images: readonly SubmitImageAttachment[] }> = [] // The service reads the generated commands Remote, which delivers the // carrier's outcome, so a programmed failure answers the error branch. const commandsRemote = { @@ -80,13 +80,13 @@ async function bench(opts: BenchOptions = {}) { return value.commands }) }, - execute: async (sessionId: SessionId, line: string) => { - executeCalls.push({ sessionId, line }) + execute: async (sessionId: SessionId, line: string, images: readonly SubmitImageAttachment[] = []) => { + executeCalls.push({ sessionId, line, images }) return await carried(async () => { const fallback = (): Promise => Promise.resolve({ matched: true }) const value = await (opts.execute ?? fallback)({ sessionId, line }) return value.matched - ? { commandId: value.commandId ?? 'fake-command', result: { kind: 'success' as const } } + ? { commandId: value.commandId ?? 'fake-command', result: value.result ?? { kind: 'success' as const } } : undefined }) }, @@ -98,6 +98,11 @@ async function bench(opts: BenchOptions = {}) { return () => { registered.delete(key) } }, }) + // Deterministic key-echo translator: notice assertions read `key{json}`. + ctx.provide('locale', { + bind: (ns: string) => (key: string, params?: Record) => + `${ns}:${key}${params === undefined ? '' : JSON.stringify(params)}`, + }) // Real scope tags behind a fake sessions face. const scopes = new Map } }>() ctx.provide('sessions', { @@ -297,9 +302,9 @@ describe('decorations (bare-invocation UI on host commands)', () => { command.decorate(goalDecoration()) const scope = mint('s1') await warm(proj('s1')) - expect(await source.matchEnter!(proj('s1'), '/goal', new AbortController().signal)).toBe('handled') + expect(await source.matchEnter!(proj('s1'), '/goal', new AbortController().signal, { images: 0 })).toBe('handled') expect(command.popupFor(scope.ctx).state.getSnapshot()).toMatchObject({ open: true, command: 'goal' }) - const argued = await source.matchEnter!(proj('s1'), '/goal ship it', new AbortController().signal) + const argued = await source.matchEnter!(proj('s1'), '/goal ship it', new AbortController().signal, { images: 0 }) if (argued === undefined || argued === 'handled' || !('claim' in argued)) throw new Error('expected the host claim') expect(argued.claim.token).toBe('/goal ') }) @@ -318,7 +323,7 @@ describe('decorations (bare-invocation UI on host commands)', () => { command.decorate(goalDecoration({ name: 'phantom' })) const scope = mint('s1') await warm(proj('s1')) - expect(await source.matchEnter!(proj('s1'), '/phantom', new AbortController().signal)).toBeUndefined() + expect(await source.matchEnter!(proj('s1'), '/phantom', new AbortController().signal, { images: 0 })).toBeUndefined() expect(menuPick(source, 'phantom', proj('s1'))).toBeUndefined() expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false) }) @@ -327,8 +332,8 @@ describe('decorations (bare-invocation UI on host commands)', () => { const { command, source, warm, executeCalls } = await bench() command.decorate(goalDecoration({ name: 'plan', available: () => false })) await warm(proj('s1')) - expect(await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal)).toBe('handled') - expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }]) + expect(await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal, { images: 0 })).toBe('handled') + expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan', images: [] }]) }) it('duplicate decoration names fail loud', async () => { @@ -383,7 +388,7 @@ describe('dispatch (menu column)', () => { expect(menuPick(source, 'plan', proj('s1'), 5)).toBe('handled') expect(consumes).toEqual([{ guard: { kind: 'span', span: { start: 0, end: 5, draftRev: 3 } } }]) await vi.waitFor(() => { - expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }]) + expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan', images: [] }]) expect(executions).toEqual([{ sessionId: sid('s1'), name: 'plan', @@ -440,7 +445,7 @@ describe('matchEnter (enter column)', () => { const { source } = await bench({ commands: () => new Promise((resolve) => { release = resolve }), }) - const wait = source.matchEnter!(proj('s1'), '/goal args', signal()) + const wait = source.matchEnter!(proj('s1'), '/goal args', signal(), { images: 0 }) release({ commands: S1_CMDS }) const outcome = await wait if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') @@ -451,14 +456,14 @@ describe('matchEnter (enter column)', () => { const { source } = await bench({ commands: () => Promise.reject(new Error('warmup boom')), }) - await expect(source.matchEnter!(proj('s1'), '/goal', signal())).rejects.toThrow('warmup boom') + await expect(source.matchEnter!(proj('s1'), '/goal', signal(), { images: 0 })).rejects.toThrow('warmup boom') }) it('leadingInput claims args-tolerant (bare and with trailing text)', async () => { const { source, warm } = await bench() await warm(proj('s1')) for (const line of ['/goal', '/goal refactor the loop']) { - const outcome = await source.matchEnter!(proj('s1'), line, signal()) + const outcome = await source.matchEnter!(proj('s1'), line, signal(), { images: 0 }) if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') expect(outcome.claim.token).toBe('/goal ') } @@ -473,16 +478,16 @@ describe('matchEnter (enter column)', () => { return true }) await warm(proj('s1')) - await expect(source.matchEnter!(proj('s1'), '/plan', signal())).resolves.toBe('handled') + await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { images: 0 })).resolves.toBe('handled') expect(consumes).toEqual([{ guard: { kind: 'bare-token', token: '/plan' } }]) await Promise.resolve() - expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }]) + expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan', images: [] }]) }) it('bare kind with trailing text → undefined and no RPC (default sink owns the line)', async () => { const { source, warm, executeCalls } = await bench() await warm(proj('s1')) - await expect(source.matchEnter!(proj('s1'), '/plan now', signal())).resolves.toBeUndefined() + await expect(source.matchEnter!(proj('s1'), '/plan now', signal(), { images: 0 })).resolves.toBeUndefined() expect(executeCalls).toEqual([]) }) @@ -490,18 +495,86 @@ describe('matchEnter (enter column)', () => { const { command, source, mint, listCalls } = await bench() command.register(themeContribution()) const scope = mint('s1') - await expect(source.matchEnter!(proj('s1'), '/theme', signal())).resolves.toBe('handled') + await expect(source.matchEnter!(proj('s1'), '/theme', signal(), { images: 0 })).resolves.toBe('handled') expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(true) expect(listCalls).toEqual([]) // contribution short-circuits ahead of ensureReady - await expect(source.matchEnter!(proj('s1'), '/theme dark', signal())).resolves.toBeUndefined() + await expect(source.matchEnter!(proj('s1'), '/theme dark', signal(), { images: 0 })).resolves.toBeUndefined() }) it('unknown name, bare "/", and non-slash lines → undefined', async () => { const { source, warm } = await bench() await warm(proj('s1')) - await expect(source.matchEnter!(proj('s1'), '/nope', signal())).resolves.toBeUndefined() - await expect(source.matchEnter!(proj('s1'), '/', signal())).resolves.toBeUndefined() - await expect(source.matchEnter!(proj('s1'), 'plain text', signal())).resolves.toBeUndefined() + await expect(source.matchEnter!(proj('s1'), '/nope', signal(), { images: 0 })).resolves.toBeUndefined() + await expect(source.matchEnter!(proj('s1'), '/', signal(), { images: 0 })).resolves.toBeUndefined() + await expect(source.matchEnter!(proj('s1'), 'plain text', signal(), { images: 0 })).resolves.toBeUndefined() + }) +}) + +describe('matchEnter envelope policy (images)', () => { + const signal = () => new AbortController().signal + const IMG_CMDS: CommandDescriptor[] = [ + ...S1_CMDS, + { name: 'vision', description: 'image-accepting leadingInput', input: { hint: 'describe', images: true } }, + ] + const png: SubmitImageAttachment = { mediaType: 'image/png', data: 'AA==' } + + it('a leadingInput command not declaring acceptance refuses; a declaring one claims with images minted', async () => { + const { source, warm } = await bench({ commands: () => Promise.resolve({ commands: IMG_CMDS }) }) + await warm(proj('s1')) + await expect(source.matchEnter!(proj('s1'), '/goal ship', signal(), { images: 1 })) + .rejects.toThrow('command:notice.imagesUnsupported{"command":"goal"}') + const outcome = await source.matchEnter!(proj('s1'), '/vision what is this', signal(), { images: 1 }) + if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') + expect(outcome.claim.token).toBe('/vision ') + expect(outcome.claim.images).toBe(true) + }) + + it('bare popup routes refuse images: contribution and decorated host both stay closed', async () => { + const { command, source, mint, warm } = await bench() + command.register(themeContribution()) + command.decorate({ name: 'plan', available: () => true, ui: themeUi() }) + const scope = mint('s1') + await warm(proj('s1')) + await expect(source.matchEnter!(proj('s1'), '/theme', signal(), { images: 1 })) + .rejects.toThrow('command:notice.imagesUnsupported{"command":"theme"}') + await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { images: 2 })) + .rejects.toThrow('command:notice.imagesUnsupported{"command":"plan"}') + expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false) + }) + + it('bare host detached execute refuses images before any RPC', async () => { + const { source, warm, executeCalls } = await bench() + await warm(proj('s1')) + await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { images: 1 })) + .rejects.toThrow('command:notice.imagesUnsupported{"command":"plan"}') + expect(executeCalls).toEqual([]) + }) + + it('claim.submit forwards the images to execute; consumption follows the handler outcome', async () => { + let result: CommandResult = { kind: 'error', text: 'handler refused' } + const { source, warm, executeCalls } = await bench({ + commands: () => Promise.resolve({ commands: IMG_CMDS }), + execute: () => Promise.resolve({ matched: true, result }), + }) + await warm(proj('s1')) + const outcome = await source.matchEnter!(proj('s1'), '/vision x', signal(), { images: 1 }) + if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') + // Handler error: the error outcome keeps draft and images in the composer. + await expect(outcome.claim.submit('x', new Context(), [png])) + .resolves.toEqual({ kind: 'error', text: 'handler refused' }) + expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/vision x', images: [png] }]) + result = { kind: 'success', text: 'described' } + await expect(outcome.claim.submit('x', new Context(), [png])).resolves.toEqual({ kind: 'success' }) + }) + + it('an imageless submission keeps the always-success admission mapping over a handler error', async () => { + const { source, warm } = await bench({ + execute: () => Promise.resolve({ matched: true, result: { kind: 'error', text: 'late failure' } }), + }) + await warm(proj('s1')) + const outcome = source.matchSpace!(proj('s1'), '/goal') + if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') + await expect(outcome.claim.submit('x', new Context(), [])).resolves.toEqual({ kind: 'success' }) }) }) @@ -513,8 +586,8 @@ describe('execute payload', () => { await warm(proj('s1')) const outcome = source.matchSpace!(proj('s1'), '/goal') if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') - const settled = await outcome.claim.submit('ship it', new Context()) - expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/goal ship it' }]) + const settled = await outcome.claim.submit('ship it', new Context(), []) + expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/goal ship it', images: [] }]) // Pure admission: no outcome text ever rides the submit result — the // durable command lifecycle events render the outcome in the flow. expect(settled).toEqual({ kind: 'success' }) @@ -539,7 +612,7 @@ describe('execute payload', () => { b.ctx.on('command/executed', rejectingListener) b.ctx.on('command/executed', after) - await expect(outcome.claim.submit('ship it', new Context())).resolves.toEqual({ kind: 'success' }) + await expect(outcome.claim.submit('ship it', new Context(), [])).resolves.toEqual({ kind: 'success' }) expect(after).toHaveBeenCalledOnce() await Promise.resolve() await Promise.resolve() @@ -557,10 +630,10 @@ describe('execute payload', () => { return outcome.claim } const first = await claimOf({ execute: () => Promise.resolve({ matched: false }) }) - const bad = await first.submit('x', new Context()) + const bad = await first.submit('x', new Context(), []) expect(bad.kind).toBe('error') const second = await claimOf({ execute: () => Promise.resolve({ matched: true }) }) - await expect(second.submit('', new Context())).resolves.toEqual({ kind: 'success' }) + await expect(second.submit('', new Context(), [])).resolves.toEqual({ kind: 'success' }) }) }) @@ -584,7 +657,7 @@ describe('detached admission notices', () => { // Admission miss (matched:false): immediate composer feedback stays. mode = 'miss' - await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal) + await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal, { images: 0 }) await flush() expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'unknown or malformed command: /plan' }]) @@ -663,7 +736,7 @@ describe('popupFor', () => { consumes.push(r) return true }) - await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal) + await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal, { images: 0 }) const popup = command.popupFor(scope.ctx) await Promise.resolve() await popup.select(0) @@ -674,7 +747,7 @@ describe('popupFor', () => { const { command, source, mint } = await bench() command.register(themeContribution()) const scope = mint('s1') - await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal) + await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal, { images: 0 }) const popup = command.popupFor(scope.ctx) expect(popup.state.getSnapshot().open).toBe(true) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 6d866e05e7..080051a1b8 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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/client/ui-conversation/README.md -README.md: d1a265b5789d9f1d9b5e630e0548ae5f619eebbf -README.zh.md: 3f303391d39bc040b4a6a5a2d1f6a34fe8891919 +README.md: d9b774bdf5bfc2beaa33fe0d3ada8263b863798d +README.zh.md: 94da3def811fb901132f53fd6dbf4de0ccd6b3c8 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index d1a265b578..d9b774bdf5 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -36,7 +36,7 @@ Keyboard message submission resolves delivery from the addressed session's runni Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. -Image intake accepts paste and whole-page drop: the bar binds document-level drag listeners (the composer-bar slot is `kind: 'single'`, so at most one bar binds them) and shows the `DropOverlay` atom while a file drag is over the window — text drags pass through untouched, and a locked or busy composer shows the blocked overlay and refuses the drop. Both gestures feed one intake pre-check against the host's `imageLimits` projection (count, per-image bytes, aggregate bytes): an addition that would break a limit is refused as a whole batch with an immediate banner naming the limit, and never enters the rail. Host-side rejections that arrive anyway surface as product copy mapped from the `attachment-error` reason (`image-labels.ts` `attachmentErrorText`); reasons the user cannot act on fold into one send-failed line carrying the reason code, and non-attachment error codes keep their developer-facing message plus code. +Image intake accepts paste and whole-page drop: the bar binds document-level drag listeners (the composer-bar slot is `kind: 'single'`, so at most one bar binds them) and shows the `DropOverlay` atom while a file drag is over the window — text drags pass through untouched, and a locked or busy composer shows the blocked overlay and refuses the drop. Both gestures feed one intake pre-check against the host's `imageLimits` projection (count, per-image bytes, aggregate bytes): an addition that would break a limit is refused as a whole batch with an immediate banner naming the limit, and never enters the rail. Host-side rejections that arrive anyway surface as product copy mapped from the `attachment-error` reason (`image-labels.ts` `attachmentErrorText`); reasons the user cannot act on fold into one send-failed line carrying the reason code, and non-attachment error codes keep their developer-facing message plus code. Attached images are part of the submission envelope on every send path: a slash-command submit either consumes them (a claim declaring `images` has them serialized through the hub's `commandImages` plumbing, passed to `claim.submit`, and cleared plus released only on a success outcome) or refuses the whole submission with the `command.imagesUnsupported` notice while draft and images stay in place — a command can never consume the text and strand the images. The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop controls), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `InputTriggerController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-input-trigger's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar keeps message actions inert (machine faces absent, `disabled` owner prop), while the whole dashed card opens the existing Workspace picker by pointer and the read-only textarea opens it through Enter or Space. Disabled controls release pointer events to the card, and the card contains `pointerdown` so the open picker's outside-close cannot race a reopen. The bar never swaps in a parallel tree, so the textarea DOM survives Workspace selection; strict-session control seats stay empty until a session exists. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 3f303391d3..94da3def81 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -36,7 +36,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu 逐会话 UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 -图片经粘贴与整页拖放进入:输入栏绑定 document 级拖拽监听(composer-bar slot 为 `kind: 'single'`,同一时刻至多一个 bar 绑定),文件拖拽悬停窗口时显示 `DropOverlay` 原子组件——纯文本拖拽不受影响,锁定或忙碌的 composer 显示禁用遮罩并拒绝 drop。两种手势共用一条对宿主 `imageLimits` 投影的加入预检(数量、单图字节、总字节):会突破上限的加入整批拒收,立刻弹出点名上限的横幅,完全不进入附件栏。仍然到达的宿主侧拒绝按 `attachment-error` 原因映射为产品文案(`image-labels.ts` 的 `attachmentErrorText`);用户无法解决的原因折叠为一条带原因码的发送失败文案,非附件错误码保留开发者可读的原文加错误码。 +图片经粘贴与整页拖放进入:输入栏绑定 document 级拖拽监听(composer-bar slot 为 `kind: 'single'`,同一时刻至多一个 bar 绑定),文件拖拽悬停窗口时显示 `DropOverlay` 原子组件——纯文本拖拽不受影响,锁定或忙碌的 composer 显示禁用遮罩并拒绝 drop。两种手势共用一条对宿主 `imageLimits` 投影的加入预检(数量、单图字节、总字节):会突破上限的加入整批拒收,立刻弹出点名上限的横幅,完全不进入附件栏。仍然到达的宿主侧拒绝按 `attachment-error` 原因映射为产品文案(`image-labels.ts` 的 `attachmentErrorText`);用户无法解决的原因折叠为一条带原因码的发送失败文案,非附件错误码保留开发者可读的原文加错误码。已附加的图片在每条发送路径上都是提交信封的一部分:斜杠命令提交要么消费它们(声明 `images` 的 claim 经 hub 的 `commandImages` 管道序列化图片、传给 `claim.submit`,仅在成功 outcome 后清除并释放),要么以 `command.imagesUnsupported` 通知拒绝整个提交,草稿与图片原样保留——命令不可能消费了文字却把图片留在原地。 输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止控件之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `InputTriggerController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-input-trigger 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 会让消息操作保持不可交互(machine face 均缺席、`disabled` owner prop),整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。禁用控件会把指针事件交给卡片,卡片也会拦下 `pointerdown`,避免已打开 picker 的外点关闭与重新打开发生竞态。它不会换入一棵平行树,因此选择 Workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。 diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index bad1e55d75..91a2494a4d 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -35,7 +35,7 @@ export interface SessionInput extends InputTarget { setDraft(text: string): void /** Append ordered browser-owned image ids; busy admission phases refuse. */ addImages(ids: readonly DraftAttachmentId[]): boolean - /** Remove one browser-owned image id. */ + /** Remove one browser-owned image id; busy admission phases refuse. */ removeImage(id: DraftAttachmentId): void /** Drop ids whose browser-owned objects no longer exist. */ pruneImages(ids: readonly DraftAttachmentId[]): void @@ -75,7 +75,7 @@ export interface InputActions { setDraft(text: string): void /** Append ordered browser-owned image ids; busy admission phases refuse. */ addImages(ids: readonly DraftAttachmentId[]): boolean - /** Remove one browser-owned image id. */ + /** Remove one browser-owned image id; busy admission phases refuse. */ removeImage(id: DraftAttachmentId): void /** Drop ids whose browser-owned objects no longer exist. */ pruneImages(ids: readonly DraftAttachmentId[]): void @@ -214,7 +214,7 @@ export interface InputState { readonly draftRev: number readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting' /** Present exactly while claimed/submitting (claim snapshot during flight; submit closure withheld). */ - readonly claim?: { readonly token: string; readonly hint?: string } + readonly claim?: { readonly token: string; readonly hint?: string; readonly images?: boolean } /** Chip occurrence table, sorted by offset (one U+FFFC per entry). */ readonly occurrences: readonly Occurrence[] /** Live paste-match attempt (absent when no paste is matchable). */ diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 35aa9197a2..2783aea854 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -10,7 +10,7 @@ import type { ClientContext, ObservableSnapshot, SnapshotStore } from '@deepseek import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome, - ReferenceInsert, InputTriggerController, TokenSpan, + ReferenceInsert, InputTriggerController, SubmitImageAttachment, TokenSpan, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { DraftAttachmentId, EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState, @@ -46,6 +46,15 @@ export interface SessionInputDeps { steerQueue?: (() => void) | undefined /** The plain-message sink (send choreography / materialize fork — the hub owns it). */ defaultSink(text: string, imageIds: readonly DraftAttachmentId[], mode: InputSubmitMode): void + /** Command-plane image plumbing (the hub owns the conversation face and the copy). */ + commandImages: { + /** Resolve ordered draft ids to wire payloads without sending them; rejects when an id no longer resolves. */ + serialize(ids: readonly DraftAttachmentId[]): Promise + /** Free consumed draft images after a successful command submit. */ + release(ids: readonly DraftAttachmentId[]): void + /** Localized composer notice for a claimed command that does not accept images. */ + unsupportedNotice(token: string): string + } } /** Guard tier from the machine phase. */ @@ -69,7 +78,7 @@ const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map() export class SessionInputShell implements SessionInput { /** Published machine state + queue overlay (the InputZone currency source). */ readonly state: SnapshotStore - /** Latest surfaced notice (null after clear); the wiring renders it beside the error strip. */ + /** Latest surfaced notice (null after clear); the bar renders errors as banners and information inline. */ readonly notices: SnapshotStore = createSnapshotStore(null) /** The public provide-channel action face (one stable identity per session). */ readonly actions: InputActions = { @@ -116,8 +125,13 @@ export class SessionInputShell implements SessionInput { return true } - /** Remove one image id from this draft. */ + /** + * Remove one image id from this draft. Busy admission phases refuse, like + * {@link addImages}: a removal landing while a command submit serializes + * would otherwise vanish from the rail yet still ride the in-flight send. + */ removeImage(id: DraftAttachmentId): void { + if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return const next = this.imageIds.filter(candidate => candidate !== id) if (next.length === this.imageIds.length) return this.imageIds = next @@ -200,6 +214,15 @@ export class SessionInputShell implements SessionInput { if (this.snapshot.phase === 'plain') this.deps.defaultSink('', [...this.imageIds], mode) return } + // Claimed pre-gate: a claim that does not declare image acceptance never + // submits while images are attached — one notice, everything retained. + // Enter-time adjudication applies the same policy for unclaimed lines + // inside the command source itself. + const before = this.snapshot + if (before.phase === 'claimed' && this.imageIds.length > 0 && before.claim?.images !== true) { + this.notify('error', this.deps.commandImages.unsupportedNotice(before.claim?.token ?? before.draft)) + return + } this.run(this.core.dispatch({ type: 'enter', mode })) const phase = this.snapshot.phase if (phase === 'adjudicating' || phase === 'submitting') { @@ -456,7 +479,7 @@ export class SessionInputShell implements SessionInput { this.run(this.core.dispatch({ type: 'adjudicated', attempt, outcome: undefined })) return } - inputTriggers.adjudicate(draft.trim(), attempt.signal).then( + inputTriggers.adjudicate(draft.trim(), attempt.signal, { images: this.imageIds.length }).then( (outcome: PickOutcome) => { if (this.dead(attempt)) return this.run(this.core.dispatch({ type: 'adjudicated', attempt, outcome })) @@ -469,13 +492,31 @@ export class SessionInputShell implements SessionInput { ) } - /** The submit transaction: claim.submit against the session scope; ok maps from the outcome kind. */ + /** + * The submit transaction: claim.submit against the session scope; ok maps + * from the outcome kind. An accepting claim receives the serialized draft + * images, which are cleared and released only on a success outcome; a + * failure (serialize, transport, or handler error) keeps draft and images + * for correction. + */ private beginSubmit(attempt: SubmitAttempt, claim: CommandClaim, args: string): void { + const imageIds = claim.images === true ? [...this.imageIds] : [] Promise.resolve() - .then(() => claim.submit(args, this.deps.actx)) + .then(async () => { + const images = imageIds.length > 0 ? await this.deps.commandImages.serialize(imageIds) : [] + // Serialization may outlive the attempt (large files, session + // teardown); a dead attempt must not reach the Host executor. + if (this.dead(attempt)) return undefined + return claim.submit(args, this.deps.actx, images) + }) .then( (outcome) => { - if (this.dead(attempt)) return + if (outcome === undefined || this.dead(attempt)) return + if (outcome.kind === 'success' && imageIds.length > 0) { + const submitted = new Set(imageIds) + this.imageIds = this.imageIds.filter(id => !submitted.has(id)) + this.deps.commandImages.release(imageIds) + } this.run(this.core.dispatch({ type: 'submit-settled', attempt, ok: outcome.kind === 'success', outcome, })) diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 54e09200ce..789f18766c 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -9,7 +9,7 @@ * real host entity, so the sink is one unconditional prompt path. */ import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client' -import type { InputTriggerController } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import type { InputTriggerController, SubmitImageAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' import { queueReadFaceOf } from '../queue/store.ts' import type { ComposerKeyboard, DraftAttachmentId, SessionInputResolver, SessionInput } from './contract.ts' @@ -30,6 +30,7 @@ interface ConversationAttachmentFace { imageIds: readonly DraftAttachmentId[], mode: InputSubmitMode, ): Promise + serializeDraftImages(imageIds: readonly DraftAttachmentId[]): Promise releaseDraftImage(id: DraftAttachmentId): void } @@ -77,6 +78,20 @@ export class InputHub implements SessionInputResolver { queue: queueReadFaceOf(session), defaultSink: (text, imageIds, mode) => { this.sink(session, text, imageIds, mode) }, steerQueue: () => { void this.steerQueue(session, shell) }, + commandImages: { + serialize: ids => this.conversation().serializeDraftImages(ids), + // Asymmetric with serialize on purpose: release settles AFTER the + // submit RPC, where session teardown may already have unloaded the + // conversation service (the same tolerance as the scope disposer + // above); leaked preview URLs then die with the document. + release: (ids) => { + const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined + for (const imageId of ids) conversation?.releaseDraftImage(imageId) + }, + unsupportedNotice: token => this.t('command.imagesUnsupported', { + command: token.trim().replace(/^\//u, ''), + }), + }, }) this.shells.set(id, shell) // The one teardown axis: listeners, shell, and map entries all ride the @@ -144,7 +159,7 @@ export class InputHub implements SessionInputResolver { * Default sink: optimistic clear + prompt. The session is always a real * host entity (materialized when its workspace was picked), so there is * exactly one path; a failed first prompt is an ordinary prompt failure - * (error strip via promptError, draft restored only while untouched). + * (banner via promptError, draft restored only while untouched). */ private sink( session: SessionFace, diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index 25fdf2fc9a..f42b827d52 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -136,7 +136,15 @@ export class InputMachine { imageIds: [], draftRev: this.draftRev, phase: this.phase, - ...(c ? { claim: { token: c.token, ...(c.hint !== undefined ? { hint: c.hint } : {}) } } : {}), + ...(c + ? { + claim: { + token: c.token, + ...(c.hint !== undefined ? { hint: c.hint } : {}), + ...(c.images === true ? { images: true } : {}), + }, + } + : {}), occurrences: this.occurrences, ...(this.paste !== undefined ? { paste: this.paste } : {}), queue: EMPTY_QUEUE, diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 368475c583..6ad7fc0e7b 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -134,6 +134,7 @@ export const zh = { 'command.failed': '命令失败', 'command.done': '已完成', 'command.title': '命令', + 'command.imagesUnsupported': '/{command} 不接受图片附件,请先移除图片', 'approval.waiting': '等待审批', 'approval.detail.aria': '审批详情', 'approval.escalation': '工具 {toolName} 请求越权执行', @@ -304,6 +305,7 @@ export const en = { 'command.failed': 'Command failed', 'command.done': 'Completed', 'command.title': 'Command', + 'command.imagesUnsupported': '/{command} does not accept image attachments; remove them first', 'approval.waiting': 'Waiting for approval', 'approval.detail.aria': 'Approval details', 'approval.escalation': 'Tool {toolName} requests privileged execution', diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 198eee5f0f..9a25e13dc0 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -13,6 +13,7 @@ import type { Context } from '@deepseek-ai/cordis' // error, so scope resolution goes through the sessions service (scopeOf // method) instead of the standalone helper. import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { SubmitImageAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' import type { ComposerAttachment } from './contract/slots.ts' import type { QueueAction, QueueItemId } from './contract/queue.ts' @@ -185,6 +186,21 @@ export class ConversationController extends Service implements IConversation { return attachments } + /** + * Serialize ordered draft images to command-submit wire payloads without + * sending or releasing them (the composer releases only after the command + * settles successfully). + * @param imageIds - ordered draft-local attachment ids. + * @returns base64 payloads in id order. + */ + async serializeDraftImages(imageIds: readonly DraftAttachmentId[]): Promise { + const attachments = this.draftImages(imageIds) + if (attachments.length !== imageIds.length) { + throw new Error('conversation.serializeDraftImages: one or more draft images are no longer available') + } + return Promise.all(attachments.map(attachment => this.encodeImage(attachment.file))) + } + /** * Release one browser-owned draft image and preview URL. * @param id - draft attachment id. @@ -314,12 +330,16 @@ export class ConversationController extends Service implements IConversation { /** Convert browser files to canonical base64 prompt parts. */ private serializeImages(images: readonly File[]): Promise[0]> { - return Promise.all(images.map(async file => ({ - type: 'image' as const, + return Promise.all(images.map(async file => ({ type: 'image' as const, ...await this.encodeImage(file) }))) + } + + /** Canonical base64 wire form of one browser image file. */ + private async encodeImage(file: File): Promise { + return { mediaType: imageMediaType(file.type), data: bytesToBase64(new Uint8Array(await file.arrayBuffer())), ...(file.name === '' ? {} : { name: file.name }), - }))) + } } } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 6635322a5a..de3a1739fc 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -24,7 +24,7 @@ /* Side pads ride the shared clearance (figma Input_Bottom drew L32/R32/B8; the sides narrow with the shared width axis); the bottom gradient mask is owned by the chat scroller. No top pad: the composer stack's gap owns - the space above; error/status strips still carry their own margin. */ + the space above; the status strip still carries its own margin. */ padding: 0 var(--dsh-composer-side-clearance) 8px; } @@ -47,11 +47,6 @@ line-height: 18px; } -.noticeError { - background: var(--dsw-alias-interactive-bg-hover-danger); - color: var(--dsw-alias-state-error-primary); -} - .card { box-sizing: border-box; position: relative; /* overlay anchor positioning context */ @@ -275,6 +270,7 @@ (figma Input_Bottom chrome). */ .row { display: flex; + flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; @@ -311,6 +307,12 @@ .trailing { flex: none; + /* Wrap keeps the left mode chips and the right controls apart when the card + runs out of row width: the trailing group (model + send) moves to its own + line instead of the left group shrinking until its chip overlaps the + model trigger (external:107). The auto margin re-anchors it right on the + wrapped line; on a single line space-between already pins it right. */ + margin-left: auto; gap: 12px; } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 501001215d..bdb5c592cd 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -65,9 +65,9 @@ export function InputBar({ [draftImages, input?.imageIds], ) const empty = draft.trim() === '' && attachments.length === 0 - // Transient error banner (image-intake rejections and prompt failures): the - // seq keys the Toast so an identical repeated message restarts the - // hold-then-fade cycle instead of silently reusing the faded one. + // Transient error banner (machine notices, image-intake rejections, and + // prompt failures): the seq keys the Toast so an identical repeated message + // restarts the hold-then-fade cycle instead of reusing the faded one. const [toast, setToast] = useState<{ seq: number; text: string } | null>(null) const toastSeq = useRef(0) const showToast = useCallback((text: string) => { @@ -91,6 +91,9 @@ export function InputBar({ ? attachmentErrorText(t, promptError.error.details.reason, imageLimits) : `${promptError.error.message} (${promptError.error.code})`) }, [promptError, showToast, t, imageLimits]) + useEffect(() => { + if (notice?.level === 'error') showToast(notice.text) + }, [notice, showToast]) const inputRef = useRef(null) const cardRef = useRef(null) const scrollRef = useRef(null) @@ -580,8 +583,8 @@ export function InputBar({ onDone={dismissToast} /> )} - {notice !== null && ( -

+ {notice?.level === 'info' && ( +
{notice.text}
)} diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index ae8be641ea..c1b57872bd 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -2,7 +2,7 @@ // InputBar behavior over the machine wiring: Enter-send semantics (IME guard, // Shift newline, busy Enter policy, Ctrl/Meta steering, repeat suppression), running // semantics (input stays free; continuable children keep Send beside Stop), the machine pending lock, -// decoration backdrop, error/notice strips, and the focus-keeping mousedown. +// decoration backdrop, error banners, status strips, and the focus-keeping mousedown. import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' @@ -117,6 +117,7 @@ function bench(over?: BenchOptions) { const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink, + commandImages: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` }, queue: { getSnapshot: () => session.getSnapshot().queue, subscribe: fn => session.subscribe(fn), @@ -1201,10 +1202,25 @@ describe('strips and variants', () => { } }) - it('renders the notice strip from the machine notice store', () => { + it('announces an error notice from the machine store as a fading toast', () => { + vi.useFakeTimers() + try { + const { view, shell } = bench() + act(() => { shell.notify('error', '命令失败了') }) + expect(view.getByRole('alert').textContent).toContain('命令失败了') + expect(view.queryByRole('status')).toBeNull() + act(() => { vi.advanceTimersByTime(4000) }) + expect(view.queryByRole('alert')).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('renders an information notice from the machine store as a status strip', () => { const { view, shell } = bench() - act(() => { shell.notify('error', '命令失败了') }) - expect(view.getByText('命令失败了')).toBeTruthy() + act(() => { shell.notify('info', '命令完成了') }) + expect(view.getByRole('status').textContent).toBe('命令完成了') + expect(view.queryByRole('alert')).toBeNull() }) it('hero variant adds the hero class and accessory row renders', () => { diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index 010077b38b..21caccca1a 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -12,9 +12,10 @@ import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, } from '@deepseek-ai/dsh-client-runtime/client' import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client' -import type { SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import type { SubmitImageAttachment, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import type { DraftAttachmentId } from '../src/client/input/contract.ts' import { SessionInputShell } from '../src/client/input/facade.ts' import { InputBar } from '../src/client/skeleton/InputBar.tsx' import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx' @@ -52,7 +53,12 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled keyboard: shell, addImages: () => null, removeImage: () => {}, - draftImages: () => [], + // Every id resolves so the bar's registry prune never drops a test image. + draftImages: ids => ids.map(id => ({ + kind: 'image' as const, id, + file: new File([Uint8Array.of(1)], `${id}.png`, { type: 'image/png' }), + previewUrl: `blob:${id}`, + })), resolveSubmitMode: () => 'queue', toggleCommandMenu: vi.fn(), useNotices: bindSnapshotSelector(shell.notices), @@ -68,25 +74,33 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled return render() } -function bench(over?: { running?: boolean; disabled?: boolean; submit?: (args: string) => Promise }) { +function bench(over?: { + running?: boolean + disabled?: boolean + submit?: (args: string) => Promise + serialize?: (ids: readonly DraftAttachmentId[]) => Promise +}) { const sink = vi.fn() - const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink }) + const serialize = vi.fn(over?.serialize ?? (() => Promise.resolve([]))) + const release = vi.fn() + const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink, commandImages: { serialize, release, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` } }) const wiring = shell const view = mountBar(shell, over) const textarea = view.container.querySelector('textarea')! - const claim = (token = '/goal ', hint = '目标') => { + const claim = (token = '/goal ', hint = '目标', images?: true) => { act(() => { shell.setDraft(token) shell.beginCommand( { token, hint, + ...(images === true ? { images: true } : {}), submit: over?.submit ?? (() => Promise.resolve({ kind: 'success' as const, source: 'command', name: 'goal' })), }, { start: 0, end: token.length, draftRev: shell.snapshot.draftRev }, ) }) } - return { view, textarea, shell, wiring, sink, claim } + return { view, textarea, shell, wiring, sink, claim, serialize, release } } describe('matrix row: plain', () => { @@ -122,7 +136,7 @@ describe('matrix row: claimed', () => { fireEvent.change(textarea, { target: { value: '/goal 发布' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) expect(sink).not.toHaveBeenCalled() - await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('发布', SCTX) }) + await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('发布', SCTX, []) }) // Commit: draft cleared, notice surfaced, back to plain. await vi.waitFor(() => { expect((textarea).value).toBe('') }) expect(view.getByText('完成')).toBeTruthy() @@ -138,6 +152,97 @@ describe('matrix row: claimed', () => { }) }) +describe('matrix row: claimed with images', () => { + const img = 'img-1' as DraftAttachmentId + + it('a claim without image acceptance blocks enter: one notice, draft/images/claim retained', async () => { + const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const })) + const { view, textarea, shell, sink, claim } = bench({ submit }) + claim() + act(() => { shell.addImages([img]) }) + fireEvent.keyDown(textarea, { key: 'Enter' }) + await Promise.resolve() + expect(shell.snapshot.phase).toBe('claimed') + expect(submit).not.toHaveBeenCalled() + expect(sink).not.toHaveBeenCalled() + expect(view.getByText('/goal images-unsupported')).toBeTruthy() + expect(shell.snapshot.imageIds).toEqual([img]) + expect((textarea).value).toBe('/goal ') + }) + + it('an accepting claim serializes and forwards the images; success consumes and clears', async () => { + const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const })) + const png: SubmitImageAttachment = { mediaType: 'image/png', data: 'AA==' } + const { textarea, shell, claim, serialize, release } = bench({ submit, serialize: () => Promise.resolve([png]) }) + claim('/goal ', '目标', true) + // The claim currency carries the acceptance flag the pre-gate reads. + expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标', images: true }) + act(() => { shell.addImages([img]) }) + fireEvent.keyDown(textarea, { key: 'Enter' }) + await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('', SCTX, [png]) }) + expect(serialize).toHaveBeenCalledWith([img]) + await vi.waitFor(() => { expect((textarea).value).toBe('') }) + expect(release).toHaveBeenCalledWith([img]) + expect(shell.snapshot.imageIds).toEqual([]) + expect(shell.snapshot.phase).toBe('plain') + }) + + it('a handler error outcome keeps the images unreleased beside the notice and the draft', async () => { + const submit = vi.fn(() => Promise.resolve({ kind: 'error' as const, text: '处理失败' })) + const { view, textarea, shell, claim, release } = bench({ submit }) + claim('/goal ', '目标', true) + act(() => { shell.addImages([img]) }) + fireEvent.keyDown(textarea, { key: 'Enter' }) + await vi.waitFor(() => { expect(view.getByText('处理失败')).toBeTruthy() }) + expect(shell.snapshot.phase).toBe('claimed') + expect(shell.snapshot.imageIds).toEqual([img]) + expect(release).not.toHaveBeenCalled() + expect((textarea).value).toBe('/goal ') + }) + + it('a serialize rejection blocks the transaction: notice, no submit call, images kept', async () => { + const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const })) + const { view, textarea, shell, claim, release } = bench({ submit, serialize: () => Promise.reject(new Error('附件已失效')) }) + claim('/goal ', '目标', true) + act(() => { shell.addImages([img]) }) + fireEvent.keyDown(textarea, { key: 'Enter' }) + await vi.waitFor(() => { expect(view.getByText('附件已失效')).toBeTruthy() }) + expect(submit).not.toHaveBeenCalled() + expect(shell.snapshot.imageIds).toEqual([img]) + expect(release).not.toHaveBeenCalled() + expect(shell.snapshot.phase).toBe('claimed') + }) + + it('a disposed shell never lets a pending serialization reach claim.submit', async () => { + const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const })) + let resolveSerialize!: (images: readonly SubmitImageAttachment[]) => void + const { shell, textarea, claim } = bench({ + submit, + serialize: () => new Promise((resolve) => { resolveSerialize = resolve }), + }) + claim('/goal ', '目标', true) + act(() => { shell.addImages([img]) }) + fireEvent.keyDown(textarea, { key: 'Enter' }) + await vi.waitFor(() => { expect(resolveSerialize).toBeDefined() }) + shell.dispose() + resolveSerialize([{ mediaType: 'image/png', data: 'AA==' }]) + await Promise.resolve() + await Promise.resolve() + expect(submit).not.toHaveBeenCalled() + }) + + it('image removal is refused while a command submit is in flight', async () => { + const submit = vi.fn(() => new Promise(() => {})) // never settles + const { shell, textarea, claim } = bench({ submit, serialize: () => Promise.resolve([]) }) + claim('/goal ', '目标', true) + act(() => { shell.addImages([img]) }) + fireEvent.keyDown(textarea, { key: 'Enter' }) + expect(shell.snapshot.phase).toBe('submitting') + act(() => { shell.removeImage(img) }) + expect(shell.snapshot.imageIds).toEqual([img]) + }) +}) + describe('matrix row: submitting', () => { it('locks enter, renders pending + read-only, keeps the claim snapshot on the currency', async () => { const submit = vi.fn(() => new Promise(() => {})) // never settles diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index 6890497f21..ea828b6bce 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -15,10 +15,13 @@ import { EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, SessionRuntime, } from '@deepseek-ai/dsh-client-runtime/client' import { InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client' -import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import type { + ClientSessionContext, CommandClaim, PickOutcome, SubmitEnvelope, SubmitImageAttachment, SubmitOutcome, +} from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { FakeApiClient, fakeRemote, ok } from '../../runtime/tests/fake-api.client.ts' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import type { DraftAttachmentId } from '../src/client/input/contract.ts' import { SessionInputShell } from '../src/client/input/facade.ts' import { InputBar } from '../src/client/skeleton/InputBar.tsx' import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx' @@ -33,20 +36,26 @@ afterEach(cleanup) interface FakeCommand { name: string description: string - input?: { hint: string } + input?: { hint: string; images?: boolean } } /** Decision-table source over an in-memory directory (menu/space/enter columns for leadingInput + execute). */ -function commandSource(commands: FakeCommand[], execute: (line: string) => Promise) { +function commandSource( + commands: FakeCommand[], + execute: (line: string, images?: readonly SubmitImageAttachment[]) => Promise, +) { const resolve = (name: string): FakeCommand | undefined => commands.find(c => c.name === name) const leadingClaim = (desc: FakeCommand): CommandClaim => ({ token: `/${desc.name} `, ...(desc.input !== undefined ? { hint: desc.input.hint } : {}), - submit: args => execute(`/${desc.name} ${args}`), + ...(desc.input?.images === true ? { images: true } : {}), + submit: (args, _actx, images) => execute(`/${desc.name} ${args}`, images), }) const executed: string[] = [] + const envelopes: SubmitEnvelope[] = [] return { executed, + envelopes, source: { trigger: '/' as const, name: 'command', @@ -68,7 +77,8 @@ function commandSource(commands: FakeCommand[], execute: (line: string) => Promi if (desc?.input === undefined) return undefined return { claim: leadingClaim(desc) } }, - matchEnter: (_session: ClientSessionContext, line: string): Promise => { + matchEnter: (_session: ClientSessionContext, line: string, _signal: AbortSignal, envelope: SubmitEnvelope): Promise => { + envelopes.push(envelope) const trimmed = line.trim() const ws = trimmed.search(/\s/) const token = ws === -1 ? trimmed : trimmed.slice(0, ws) @@ -87,8 +97,11 @@ function commandSource(commands: FakeCommand[], execute: (line: string) => Promi const COMMANDS: FakeCommand[] = [ { name: 'goal', description: '设定目标', input: { hint: '目标内容' } }, { name: 'compact', description: '压缩上下文' }, + { name: 'vision', description: '识别图片', input: { hint: '想问什么', images: true } }, ] +const PNG: SubmitImageAttachment = { mediaType: 'image/png', data: 'AA==' } + /** Real scope bench: SessionRuntime over one listed session + InputTriggerController + shell listeners (the hub wiring shape). */ async function scopedBench(register?: (inputTriggers: InputTriggerService) => void) { const ctx = new Context() @@ -107,7 +120,9 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo const actx = sessions.scope(sessionId)! const controller = inputTriggers.sessionOf(actx) const sink = vi.fn() - const shell = new SessionInputShell({ actx, inputTriggers: () => controller, defaultSink: sink }) + const serialize = vi.fn((ids: readonly DraftAttachmentId[]) => Promise.resolve(ids.map(() => PNG))) + const release = vi.fn() + const shell = new SessionInputShell({ actx, inputTriggers: () => controller, defaultSink: sink, commandImages: { serialize, release, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` } }) // The hub's listener wiring, verbatim. actx.on('slash/input-begin-command', req => shell.beginCommand(req.claim, req.span) ? true : undefined) actx.on('slash/input-insert-reference', req => shell.insertReference(req.reference, req.span) ? true : undefined) @@ -138,7 +153,12 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo keyboard: shell, addImages: () => null, removeImage: () => {}, - draftImages: () => [], + // Every id resolves so the bar's registry prune never drops a test image. + draftImages: ids => ids.map(id => ({ + kind: 'image' as const, id, + file: new File([Uint8Array.of(1)], `${id}.png`, { type: 'image/png' }), + previewUrl: `blob:${id}`, + })), resolveSubmitMode: () => 'queue', toggleCommandMenu: (selection) => { const snapshot = shell.snapshot @@ -164,15 +184,15 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo const type = (text: string): void => { fireEvent.change(textarea, { target: { value: text } }) } - return { ctx, inputTriggers, controller, shell, wiring, view, textarea, type, sink } + return { ctx, inputTriggers, controller, shell, wiring, view, textarea, type, sink, serialize, release } } async function bench(executeImpl?: (line: string) => Promise) { const execute = vi.fn(executeImpl ?? ((line: string) => Promise.resolve({ kind: 'success' as const, text: `已执行 ${line}` }))) - const { source, executed } = commandSource(COMMANDS, execute) + const { source, executed, envelopes } = commandSource(COMMANDS, execute) const base = await scopedBench((inputTriggers) => { inputTriggers.registerSource(source) }) - return { ...base, execute, executed } + return { ...base, execute, executed, envelopes } } describe('scenario A: menu-pick /goal, type args, enter submits', () => { @@ -197,7 +217,7 @@ describe('scenario A: menu-pick /goal, type args, enter submits', () => { expect(b.shell.snapshot.phase).toBe('claimed') // Enter: submitting → command execute → commit clears. fireEvent.keyDown(b.textarea, { key: 'Enter' }) - await vi.waitFor(() => { expect(b.execute).toHaveBeenCalledWith('/goal 发布 v1') }) + await vi.waitFor(() => { expect(b.execute).toHaveBeenCalledWith('/goal 发布 v1', []) }) await vi.waitFor(() => { expect(b.textarea.value).toBe('') }) expect(b.shell.snapshot.phase).toBe('plain') expect(b.view.getByText('已执行 /goal 发布 v1')).toBeTruthy() @@ -212,7 +232,7 @@ describe('scenario C: pasted /goal xxx + enter (menu never opened)', () => { // the caret mid-whitespace — menu stays closed; enter runs adjudication. act(() => { b.shell.setDraft('/goal 尽快发布') }) fireEvent.keyDown(b.textarea, { key: 'Enter' }) - await vi.waitFor(() => { expect(b.execute).toHaveBeenCalledWith('/goal 尽快发布') }) + await vi.waitFor(() => { expect(b.execute).toHaveBeenCalledWith('/goal 尽快发布', []) }) await vi.waitFor(() => { expect(b.shell.snapshot.phase).toBe('plain') }) expect(b.textarea.value).toBe('') expect(b.sink).not.toHaveBeenCalled() @@ -247,6 +267,33 @@ describe('scenario D: execute-kind /compact', () => { }) }) +describe('scenario: images ride an accepting command through the real pipeline', () => { + it('adjudication reports the image count; the claim chain serializes, submits, and consumes', async () => { + const b = await bench() + act(() => { b.shell.addImages(['img-1' as DraftAttachmentId]) }) + act(() => { b.shell.setDraft('/vision 这张图是什么') }) + fireEvent.keyDown(b.textarea, { key: 'Enter' }) + await vi.waitFor(() => { expect(b.execute).toHaveBeenCalledWith('/vision 这张图是什么', [PNG]) }) + // The envelope the controller forwarded to matchEnter carried the count. + expect(b.envelopes).toEqual([{ images: 1 }]) + expect(b.serialize).toHaveBeenCalledWith(['img-1']) + await vi.waitFor(() => { expect(b.textarea.value).toBe('') }) + expect(b.release).toHaveBeenCalledWith(['img-1']) + expect(b.shell.snapshot.imageIds).toEqual([]) + expect(b.sink).not.toHaveBeenCalled() + }) + + it('an imageless enter adjudicates with a zero-image envelope', async () => { + const b = await bench() + act(() => { b.shell.setDraft('/goal 发布') }) + fireEvent.keyDown(b.textarea, { key: 'Enter' }) + await vi.waitFor(() => { expect(b.execute).toHaveBeenCalledWith('/goal 发布', []) }) + expect(b.envelopes).toEqual([{ images: 0 }]) + expect(b.serialize).not.toHaveBeenCalled() + expect(b.release).not.toHaveBeenCalled() + }) +}) + describe('scenario H: backspace breaks the token', () => { it('claim releases automatically; the enter after that goes through adjudication again', async () => { const b = await bench() diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index 003857968e..d8b475579f 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -32,7 +32,7 @@ import type { ViewTab } from '../src/client/contract/views.ts' /** Machine-backed wiring over a sink spy. */ function fakeWiring() { const sink = vi.fn() - const shell = new SessionInputShell({ actx: {} as ClientContext, defaultSink: sink }) + const shell = new SessionInputShell({ actx: {} as ClientContext, defaultSink: sink, commandImages: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` } }) return { wiring: shell, sink, shell } } diff --git a/packages/client/ui-input-trigger/README.i18n.yaml b/packages/client/ui-input-trigger/README.i18n.yaml index 069c8e0ca7..12966cb57e 100644 --- a/packages/client/ui-input-trigger/README.i18n.yaml +++ b/packages/client/ui-input-trigger/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/client/ui-input-trigger/README.md -README.md: f1858d3b506826d4e5eeb8d101b9bc216c1c0615 -README.zh.md: 655b3f0458062ae7b578375415a1e28f6d3171dd +README.md: 917a0be02d48260704be8dc2c2f70504138c1957 +README.zh.md: cf33c51c40edd53a492416b9654cb9e69680aebd diff --git a/packages/client/ui-input-trigger/README.md b/packages/client/ui-input-trigger/README.md index f1858d3b50..917a0be02d 100644 --- a/packages/client/ui-input-trigger/README.md +++ b/packages/client/ui-input-trigger/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.inputTriggers` owns the source roster and resolves one `InputTriggerController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. The same controller exposes `toggleSource` for a chrome launcher to open exactly one registered source over a synthetic selection span; the resulting candidates still use the ordinary menu, keyboard arbitration, pick callback, and scoped input mutations. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone. A source is warmed in every session controller it can reach: the roster present at scope birth warms during controller construction, and a source registered later is warmed into every live controller by the registration itself. Sources whose `lexicon` roll changes after warm implement `subscribeLexicon(session, listener)`; the controller re-polls on each notification and publishes the aggregation through its `lexicon` snapshot store. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins. +Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.inputTriggers` owns the source roster and resolves one `InputTriggerController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. The same controller exposes `toggleSource` for a chrome launcher to open exactly one registered source over a synthetic selection span; the resulting candidates still use the ordinary menu, keyboard arbitration, pick callback, and scoped input mutations. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone. A source is warmed in every session controller it can reach: the roster present at scope birth warms during controller construction, and a source registered later is warmed into every live controller by the registration itself. Sources whose `lexicon` roll changes after warm implement `subscribeLexicon(session, listener)`; the controller re-polls on each notification and publishes the aggregation through its `lexicon` snapshot store. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins. Enter adjudication also carries a `SubmitEnvelope` (the composer's image-attachment count) so a source can refuse a submission it cannot consume whole; a `CommandClaim` declares `images: true` when its command accepts composer images, and its `submit` then receives the serialized payloads as a third argument. Layering: `src/core/` is the pure core — `detectTrigger`, `menuReduce`/`seedGroups`/`MENU_CLOSED`, `exactMatch`, zero React/DOM/cordis; `src/client/service.ts` is the shell wiring the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources drop silently with a console record), and the three pick paths. `src/types.ts` and the two `contract.ts` files are the frozen cross-package contract; changes require main-thread arbitration. diff --git a/packages/client/ui-input-trigger/README.zh.md b/packages/client/ui-input-trigger/README.zh.md index 655b3f0458..cf33c51c40 100644 --- a/packages/client/ui-input-trigger/README.zh.md +++ b/packages/client/ui-input-trigger/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -输入触发流水线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.inputTriggers` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `InputTriggerController`;对话接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。同一个 controller 还暴露 `toggleSource`,供 chrome launcher 在一段合成 selection span 上只打开一个已注册 source;所得候选仍走通常的菜单、键盘仲裁、pick callback 与 scoped 输入改写。source 每次调用收到一个 `ClientSessionContext` 投影——会话始终由 agent(智能体)支撑,因此投影只含会话身份。source 在它能触达的每个会话 controller 中都会被预热:scope 创建时 roster 中已有的 source 会在 controller 构造期间预热,晚于此注册的 source 由注册动作本身预热进每个仍存续的 controller。`lexicon` 名录在预热后仍会变化的 source 实现 `subscribeLexicon(session, listener)`;controller 每收到通知就重拉,并把聚合结果经其 `lexicon` 快照 store 发布。流水线与命令无关:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。 +输入触发流水线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.inputTriggers` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `InputTriggerController`;对话接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。同一个 controller 还暴露 `toggleSource`,供 chrome launcher 在一段合成 selection span 上只打开一个已注册 source;所得候选仍走通常的菜单、键盘仲裁、pick callback 与 scoped 输入改写。source 每次调用收到一个 `ClientSessionContext` 投影——会话始终由 agent(智能体)支撑,因此投影只含会话身份。source 在它能触达的每个会话 controller 中都会被预热:scope 创建时 roster 中已有的 source 会在 controller 构造期间预热,晚于此注册的 source 由注册动作本身预热进每个仍存续的 controller。`lexicon` 名录在预热后仍会变化的 source 实现 `subscribeLexicon(session, listener)`;controller 每收到通知就重拉,并把聚合结果经其 `lexicon` 快照 store 发布。流水线与命令无关:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。回车裁决还携带 `SubmitEnvelope`(composer 的图片附件数量),使 source 能拒绝它无法整体消费的提交;命令接受 composer 图片时,`CommandClaim` 声明 `images: true`,其 `submit` 随之以第三个参数收到序列化后的图片载荷。 分层:`src/core/` 是纯内核——`detectTrigger`、`menuReduce`/`seedGroups`/`MENU_CLOSED`、`exactMatch`,零 React/DOM/cordis;`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包约定;变更需经主线程仲裁。 diff --git a/packages/client/ui-input-trigger/src/client/controller.ts b/packages/client/ui-input-trigger/src/client/controller.ts index 5a6bcb7b21..8dd49fd541 100644 --- a/packages/client/ui-input-trigger/src/client/controller.ts +++ b/packages/client/ui-input-trigger/src/client/controller.ts @@ -13,7 +13,7 @@ import { detectTrigger } from '../core/detect.ts' import { MENU_CLOSED, menuReduce, seedGroups } from '../core/menu.ts' import type { MenuEvent, MenuState, TriggerHit } from '../core/contract.ts' import type { - ArbitrateKey, ArbitrateOutcome, ClientSessionContext, PickOutcome, InputTriggerSource, TriggerChar, TriggerGuard, + ArbitrateKey, ArbitrateOutcome, ClientSessionContext, PickOutcome, InputTriggerSource, SubmitEnvelope, TriggerChar, TriggerGuard, } from '../types.ts' /** Roster access the controller borrows from the root service (registration order preserved). */ @@ -248,17 +248,19 @@ export class InputTriggerController { * input machine applies it inside the same submit attempt — no event). * @param line - trimmed draft; the leading char selects the trigger roster. * @param signal - attempt-scoped abort from the input machine. + * @param envelope - non-text submission state accompanying the draft. * @returns the winning outcome or undefined (default sink). Rejects when a - * polled source's warmup fails — the caller must not silently downgrade. + * polled source's warmup fails or the winning source refuses the envelope — + * the caller must not silently downgrade. */ - async adjudicate(line: string, signal: AbortSignal): Promise { + async adjudicate(line: string, signal: AbortSignal, envelope: SubmitEnvelope): Promise { const projection = this.project() for (const src of this.deps.roster.all()) { if (signal.aborted) { throw signal.reason instanceof Error ? signal.reason : new Error('slash adjudication aborted') } if (src.matchEnter === undefined || !line.startsWith(src.trigger)) continue - const outcome = await src.matchEnter(projection, line, signal) + const outcome = await src.matchEnter(projection, line, signal, envelope) if (outcome !== undefined) return outcome } return undefined diff --git a/packages/client/ui-input-trigger/src/client/index.ts b/packages/client/ui-input-trigger/src/client/index.ts index ea5a63063f..0e7f1af099 100644 --- a/packages/client/ui-input-trigger/src/client/index.ts +++ b/packages/client/ui-input-trigger/src/client/index.ts @@ -21,8 +21,8 @@ export type { MenuKey } from './locales.ts' export type { ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CandidateRequest, ClientSessionContext, CommandClaim, ConsumeTokenRequest, InsertReferenceRequest, PickOutcome, PickVia, ReferenceCodec, - ReferenceInsert, InputTriggerCandidate, InputTriggerPick, InputTriggerSource, SubmitOutcome, TokenSpan, - TriggerChar, TriggerGuard, TriggerPosition, + ReferenceInsert, InputTriggerCandidate, InputTriggerPick, InputTriggerSource, SubmitEnvelope, + SubmitImageAttachment, SubmitOutcome, TokenSpan, TriggerChar, TriggerGuard, TriggerPosition, } from '../types.ts' export type { DetectTrigger, ExactMatch, MenuEvent, MenuReduce, MenuState, TriggerHit } from '../core/contract.ts' export type { InputTriggerServiceContract } from './contract.ts' diff --git a/packages/client/ui-input-trigger/src/types.ts b/packages/client/ui-input-trigger/src/types.ts index 85cbb87f5a..bbbbeefd88 100644 --- a/packages/client/ui-input-trigger/src/types.ts +++ b/packages/client/ui-input-trigger/src/types.ts @@ -44,6 +44,16 @@ export interface TokenSpan { readonly draftRev: number } +/** Base64-encoded composer image accompanying one claimed submit transaction. */ +export interface SubmitImageAttachment { + /** Declared media type; the host verifies it against the decoded bytes. */ + readonly mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' + /** Canonical base64 encoding of the image bytes. */ + readonly data: string + /** Optional display name; never interpreted as a path. */ + readonly name?: string +} + /** * Command-mode entry credential. Pure data + a closure method — no class, no * cross-package runtime value (client bundle purity). @@ -53,8 +63,18 @@ export interface CommandClaim { readonly token: string /** Ghost-text hint rendered while the claim's args are blank. */ readonly hint?: string - /** Enter transaction, supplied by the source as a closure. */ - submit(args: string, actx: ClientContext): Promise + /** + * Whether composer image attachments may accompany this command's submit. + * Absent = the composer refuses to submit while images are attached, keeping + * the draft and the images in place behind a visible notice. + */ + readonly images?: boolean + /** + * Enter transaction, supplied by the source as a closure. + * @param images - serialized composer images accompanying the submission; + * the composer passes them only when {@link CommandClaim.images} is true. + */ + submit(args: string, actx: ClientContext, images: readonly SubmitImageAttachment[]): Promise } /** @@ -94,6 +114,16 @@ export type PickOutcome = | 'handled' | undefined +/** + * Non-text composer submission state visible to enter adjudication. The + * composer owns the actual attachment payloads; adjudication only needs their + * presence to accept or refuse a whole submission. + */ +export interface SubmitEnvelope { + /** Number of image attachments accompanying the draft. */ + readonly images: number +} + /** Candidate request passed to a source. The signal is superseded on query change / menu close. */ export interface CandidateRequest { readonly query: string @@ -151,9 +181,17 @@ export interface InputTriggerSource { * reject on warmup failure. `line` is the full trimmed draft: the source * parses it and applies its own kind policy — args-tolerant kinds claim * with trailing text present, bare-token-only kinds answer undefined - * unless the line is exactly the token. + * unless the line is exactly the token. `envelope` describes the rest of + * the composer submission; a source that would consume the line but cannot + * consume the whole envelope throws to surface the refusal and leave the + * submission intact. */ - matchEnter?(session: ClientSessionContext, line: string, signal: AbortSignal): Promise + matchEnter?( + session: ClientSessionContext, + line: string, + signal: AbortSignal, + envelope: SubmitEnvelope, + ): Promise /** * Scope-birth prewarm hook (fire-and-forget): the per-session controller * calls it once when the session scope comes alive so sources can fetch diff --git a/packages/client/ui-input-trigger/tests/service.client.spec.ts b/packages/client/ui-input-trigger/tests/service.client.spec.ts index 335f5a6aed..6bf3c02017 100644 --- a/packages/client/ui-input-trigger/tests/service.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/service.client.spec.ts @@ -796,7 +796,7 @@ describe('adjudicate', () => { return Promise.resolve('handled') }), ]) - const result = await controller.adjudicate('/goal make it fast', new AbortController().signal) + const result = await controller.adjudicate('/goal make it fast', new AbortController().signal, { images: 0 }) expect(result).toEqual({ claim }) expect(calls).toEqual(['first:/goal make it fast', 'second:/goal make it fast']) }) @@ -807,16 +807,34 @@ describe('adjudicate', () => { enterSource('@', 'subagent', atHook), enterSource('/', 'command', () => Promise.resolve(undefined)), ]) - await expect(controller.adjudicate('/xyz', new AbortController().signal)).resolves.toBeUndefined() + await expect(controller.adjudicate('/xyz', new AbortController().signal, { images: 0 })).resolves.toBeUndefined() expect(atHook).not.toHaveBeenCalled() }) + it('forwards the caller envelope to every polled matchEnter unchanged', async () => { + const envelopes: unknown[] = [] + const { controller } = controllerBench([ + enterSource('/', 'first', (_session, _line, _signal, envelope) => { + envelopes.push(envelope) + return Promise.resolve(undefined) + }), + enterSource('/', 'second', (_session, _line, _signal, envelope) => { + envelopes.push(envelope) + return Promise.resolve('handled') + }), + ]) + const envelope = { images: 2 } + await controller.adjudicate('/goal', new AbortController().signal, envelope) + expect(envelopes).toEqual([envelope, envelope]) + expect(envelopes[0]).toBe(envelope) + }) + it('a rejecting source rejects the whole adjudication', async () => { const { controller } = controllerBench([ enterSource('/', 'command', () => Promise.reject(new Error('warmup failed'))), enterSource('/', 'late', () => Promise.resolve('handled')), ]) - await expect(controller.adjudicate('/goal x', new AbortController().signal)) + await expect(controller.adjudicate('/goal x', new AbortController().signal, { images: 0 })) .rejects.toThrow('warmup failed') }) @@ -825,7 +843,7 @@ describe('adjudicate', () => { const { controller } = controllerBench([enterSource('/', 'command', hook)]) const abort = new AbortController() abort.abort(new Error('attempt released')) - await expect(controller.adjudicate('/goal', abort.signal)).rejects.toThrow('attempt released') + await expect(controller.adjudicate('/goal', abort.signal, { images: 0 })).rejects.toThrow('attempt released') expect(hook).not.toHaveBeenCalled() }) }) diff --git a/packages/client/ui-plan/src/client/index.ts b/packages/client/ui-plan/src/client/index.ts index 4fe7bd3e37..a19a028bb6 100644 --- a/packages/client/ui-plan/src/client/index.ts +++ b/packages/client/ui-plan/src/client/index.ts @@ -55,7 +55,7 @@ export function apply(ctx: ClientContext): void { inject: (sessionId: SessionId): PlanChipInjected => ({ // Failure strings stay English (error-surface policy: not localized). exitPlanMode: async () => { - const result = await ctx.remote.commands.execute(sessionId, '/plan off') + const result = await ctx.remote.commands.execute(sessionId, '/plan off', []) if (!result.ok) return `${result.error.message} (${result.error.code})` if (result.value === undefined) return 'unknown command: /plan off' return null diff --git a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts index 1bbb2d633b..347f38d552 100644 --- a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts @@ -68,7 +68,7 @@ describe('ui-plan browser apply', () => { const injected = (entry.inject as unknown as (id: SessionId) => PlanChipInjected)(SID) await expect(injected.exitPlanMode()).resolves.toBeNull() - expect(b.execute).toHaveBeenLastCalledWith(SID, '/plan off') + expect(b.execute).toHaveBeenLastCalledWith(SID, '/plan off', []) // Business failure folds to the composer-visible line: the generated method // reports the RPC failure in its error branch. diff --git a/packages/compaction/command-compact/tests/command-compact.spec.ts b/packages/compaction/command-compact/tests/command-compact.spec.ts index 422e6eaba7..97229590b7 100644 --- a/packages/compaction/command-compact/tests/command-compact.spec.ts +++ b/packages/compaction/command-compact/tests/command-compact.spec.ts @@ -110,7 +110,7 @@ async function run( suffix = '', controller = new AbortController(), ): Promise>>> { - const execution = await test.ctx.commands.execute(test.agent, `/compact${suffix}`, controller.signal) + const execution = await test.ctx.commands.execute(test.agent, `/compact${suffix}`, [], controller.signal) if (execution === undefined) throw new Error('compact command was not registered') return execution } diff --git a/packages/compaction/command-compact/tests/loader-composition.spec.ts b/packages/compaction/command-compact/tests/loader-composition.spec.ts index bb1d04a872..95358b0036 100644 --- a/packages/compaction/command-compact/tests/loader-composition.spec.ts +++ b/packages/compaction/command-compact/tests/loader-composition.spec.ts @@ -123,7 +123,7 @@ describe('command-compact real Loader composition', () => { name: 'compact', description: 'Compact older conversation history', }) - const execution = await context.commands.execute(agent, '/compact', new AbortController().signal) + const execution = await context.commands.execute(agent, '/compact', [], new AbortController().signal) if (execution === undefined) throw new Error('Loader composition did not resolve /compact') expect(execution.result).toEqual({ kind: 'success', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 707431600b..f6f998a383 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -468,9 +468,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the scoped shadow or global definition.', }, { - signature: '@Remote async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise', - description: 'Parse and execute a known command without sending it to the model.\n\nA resolved command\'s lifecycle is logged: `command/run` is appended before the handler is invoked and `command/done` after settlement (a thrown or aborted handler settles as `kind: \'error\'`). Both are direct log-only appends — no turn wraps them, and persistence drains them at ordinary checkpoints. Admission misses (syntax or unknown name) log nothing — they never entered a handler. A `command/run` append failure fails the execution loud; a `command/done` append failure on the handler-failure path is contained so the handler\'s own error stays the reported failure.', - parameters: [{ name: 'agent', description: 'exact receiving agent.' }, { name: 'line', description: 'complete slash-command line.' }, { name: 'signal', description: 'cancellation signal owned by the UI request.' }], + signature: '@Remote async execute( agent: Agent, line: string, images: readonly EncodedImageAttachment[], signal: AbortSignal, ): Promise', + description: 'Parse and execute a known command without sending it to the model.\n\nA resolved command\'s lifecycle is logged: `command/run` is appended before the handler is invoked and `command/done` after settlement (a thrown or aborted handler settles as `kind: \'error\'`). Both are direct log-only appends — no turn wraps them, and persistence drains them at ordinary checkpoints. Admission misses (syntax or unknown name) log nothing — they never entered a handler. A `command/run` append failure fails the execution loud; a `command/done` append failure on the handler-failure path is contained so the handler\'s own error stays the reported failure.\n\nImage admission is enforced here, not in the composer: images sent to a command that does not declare `input.images`, an absent attachment store, and an exceeded attachment limit each settle as an error result before the handler runs, and a rejected batch publishes no durable object.', + parameters: [{ name: 'agent', description: 'exact receiving agent.' }, { name: 'line', description: 'complete slash-command line.' }, { name: 'images', description: 'base64-encoded composer images accompanying the line, in submission order; empty for a plain invocation.' }, { name: 'signal', description: 'cancellation signal owned by the UI request.' }], returns: 'the settled execution (result + lifecycle pairing id), or `undefined` when syntax or name does not resolve.', }, ], @@ -2881,11 +2881,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CommandInputDescriptor', - declaration: 'export interface CommandInputDescriptor {\n readonly hint: string;\n}', + declaration: 'export interface CommandInputDescriptor {\n readonly hint: string;\n readonly images?: boolean;\n}', }, { name: 'CommandInvocation', - declaration: 'export interface CommandInvocation {\n readonly commandId: CommandId;\n readonly agent: Agent;\n readonly rawInput: string;\n readonly signal: AbortSignal;\n}', + declaration: 'export interface CommandInvocation {\n readonly commandId: CommandId;\n readonly agent: Agent;\n readonly rawInput: string;\n readonly attachments: readonly ImageBlock[];\n readonly signal: AbortSignal;\n}', }, { name: 'CommandResult', @@ -3115,6 +3115,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'EditGoalRequest', declaration: 'export interface EditGoalRequest {\n readonly objective?: string;\n readonly maxGoalRounds?: number;\n}', }, + { + name: 'EncodedImageAttachment', + declaration: 'export interface EncodedImageAttachment {\n mediaType: ImageMediaType;\n data: string;\n name?: string;\n}', + }, { name: 'EpochHeader', declaration: 'export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n}', diff --git a/packages/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts index 9105e8d5f3..3c567df332 100644 --- a/packages/feedback/command-feedback/tests/command-feedback.spec.ts +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts @@ -85,6 +85,7 @@ async function run(test: Harness, suffix = ''): Promise<{ kind: string; text?: s 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') @@ -168,8 +169,8 @@ describe('/feedback human command', () => { 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), + 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 for session ${test.session.id}\nAnonymous user: ${USER_ID}. Session sharing is not configured.` }, @@ -238,7 +239,7 @@ describe('/feedback human command', () => { 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)) + 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 index 7ff8c2e4cd..d133d86830 100644 --- a/packages/feedback/command-feedback/tests/loader-composition.spec.ts +++ b/packages/feedback/command-feedback/tests/loader-composition.spec.ts @@ -89,13 +89,13 @@ describe('/feedback real Loader composition through cordis.yml', () => { // 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) + const accepted = await context.commands.execute(owner, '/feedback the diff view is unreadable', [], signal) const userId = getOrCreateAnonymousUserId({ env: { DSH_HOME: root } }) expect(accepted?.result).toEqual({ kind: 'success', text: `Feedback recorded for session feedback-loader-agent\nAnonymous user: ${userId}. Session sharing is not configured.`, }) - const rejected = await context.commands.execute(owner, '/feedback', signal) + const rejected = await context.commands.execute(owner, '/feedback', [], signal) expect(rejected?.result).toEqual({ kind: 'error', text: 'Feedback text is required. Usage: /feedback ', diff --git a/packages/goal/command-goal/README.i18n.yaml b/packages/goal/command-goal/README.i18n.yaml index f98588640f..ac6f7b71aa 100644 --- a/packages/goal/command-goal/README.i18n.yaml +++ b/packages/goal/command-goal/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/goal/command-goal/README.md -README.md: ee32ea9e5b90d79f9912d7817d6f14b2227ece64 -README.zh.md: ee40cd4219c45b6d9650036fda21c56599be6abd +README.md: 483f756517f511ec506868b008b88e99b2477dc0 +README.zh.md: cd3c396ad446721de4cc091d511b615149ab2b30 diff --git a/packages/goal/command-goal/README.md b/packages/goal/command-goal/README.md index ee32ea9e5b..483f756517 100644 --- a/packages/goal/command-goal/README.md +++ b/packages/goal/command-goal/README.md @@ -17,6 +17,8 @@ Human-facing `/goal` control over [`ctx.goals`](../goal/README.md). The plugin r Control words are case-insensitive only when they occupy the complete input. Every other non-empty suffix is an objective, so `/goal pause after verification` creates that literal objective. The goal domain trims and validates objectives. Because the generic command plane has no modal editor or confirmation primitive, `edit` takes its replacement inline and an unfinished replacement returns a direct error instructing the user to edit or clear. +The command declares `input.images`, so composer image attachments may accompany an invocation. Attachments only accompany an objective: on a successful create or edit the producer submits one user followup carrying the admitted image blocks plus the fixed text `Reference images for the goal objective.`, so later goal rounds read them from ordinary session history without the goal domain storing attachment state. Every other sub-command, and any refused create or edit, returns a direct error and submits nothing, so the dispatching composer keeps the images. + Expected domain rejections become stable direct command errors without exposing branded ids or revisions. Unexpected implementation failures still reject dispatch so adapters can report them as command failures. Generic command text and output remain live UI state; `dsh-goal` persists every accepted mutation through its own durable `goal/change` event. ## Composition @@ -40,11 +42,11 @@ The shipped `dsh` base enables the persisted-goal stack and this command; the We #### What the model sees -The slash input, mutation, and direct status/error output are absent from model requests. The goal domain records the mutation as `goal/change`; an enabled same-session driver may expose the resulting state in a later continuation prompt. Presentation text is never logged. +The slash input, mutation, and direct status/error output are absent from model requests. The goal domain records the mutation as `goal/change`; an enabled same-session driver may expose the resulting state in a later continuation prompt. Presentation text is never logged. When a create or edit carries image attachments, the model sees one ordinary user message: the image blocks followed by the text `Reference images for the goal objective.`; it precedes the next goal round in session history. #### Token effect -Reading status, mutating a goal, or receiving a direct command error adds no model tokens. An enabled same-session driver may add later goal-round prompts. +Reading status, mutating a goal, or receiving a direct command error adds no model tokens. An enabled same-session driver may add later goal-round prompts. An objective's image attachments add one user message billed like any image prompt. #### KV Cache effect diff --git a/packages/goal/command-goal/README.zh.md b/packages/goal/command-goal/README.zh.md index ee40cd4219..cd3c396ad4 100644 --- a/packages/goal/command-goal/README.zh.md +++ b/packages/goal/command-goal/README.zh.md @@ -17,6 +17,8 @@ 只有控制词占据完整输入时才不区分大小写。其他任何非空后缀都属于目标,因此 `/goal pause after verification` 会创建该字面目标。goal 领域会去除目标首尾空白并进行验证。由于通用命令平面没有模态编辑器或确认原语,`edit` 会内联接收替换内容;若试图替换未完成的 goal,则直接返回错误,提示用户执行 edit 或 clear。 +该命令声明了 `input.images`,因此 composer 图片附件可以随调用一起提交。附件只随目标本身:create 或 edit 成功时,生产方提交一条用户 followup 消息,内容为已准入的图片块加固定文本 `Reference images for the goal objective.`,后续 Goal Round 从普通会话历史中读取它们,goal 领域不存储附件状态。其他任何子命令、以及被拒绝的 create 或 edit,都直接返回错误且不提交任何消息,分发方 composer 保留图片。 + 可预期的领域拒绝会变成稳定的直接命令错误,不公开带品牌类型的 id 或 revision。意外实现失败仍会 reject 分发,使适配器能将其报告为命令失败。通用命令文本和输出仍属于实时 UI 状态;`dsh-goal` 通过自有的持久 `goal/change` 事件记录每项已接受变更。 ## 组合 @@ -40,11 +42,11 @@ #### 模型看到的内容 -斜杠输入、变更以及直接状态/错误输出不会进入模型请求。goal 领域把变更记录为 `goal/change`;已启用的同会话驱动器可以在后续继续执行提示词中暴露结果状态。呈现文本绝不会记录到日志中。 +斜杠输入、变更以及直接状态/错误输出不会进入模型请求。goal 领域把变更记录为 `goal/change`;已启用的同会话驱动器可以在后续继续执行提示词中暴露结果状态。呈现文本绝不会记录到日志中。当 create 或 edit 携带图片附件时,模型会看到一条普通用户消息:图片块后跟文本 `Reference images for the goal objective.`,在会话历史中位于下一个 Goal Round 之前。 #### Token 影响 -读取状态、变更 goal 或收到直接命令错误不会增加模型 token。已启用的同会话驱动器可能增加后续 Goal Round 提示词。 +读取状态、变更 goal 或收到直接命令错误不会增加模型 token。已启用的同会话驱动器可能增加后续 Goal Round 提示词。目标携带的图片附件会增加一条用户消息,其计费与任何图片提示词相同。 #### KV Cache 影响 diff --git a/packages/goal/command-goal/package.json b/packages/goal/command-goal/package.json index 54d4c16a5e..f21ecb6333 100644 --- a/packages/goal/command-goal/package.json +++ b/packages/goal/command-goal/package.json @@ -35,6 +35,7 @@ "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { diff --git a/packages/goal/command-goal/src/index.ts b/packages/goal/command-goal/src/index.ts index 38d18e2529..e1b6e79eed 100644 --- a/packages/goal/command-goal/src/index.ts +++ b/packages/goal/command-goal/src/index.ts @@ -7,6 +7,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands' import { GoalError } from '@deepseek-ai/dsh-goal' import type { GoalPhase, GoalRef, GoalView } from '@deepseek-ai/dsh-goal' +import { createUserMessage } from '@deepseek-ai/dsh-llm' export const name = 'command-goal' export const inject = ['commands', 'goals'] @@ -106,9 +107,29 @@ function missingGoal(action: string): CommandResult { } } +/** + * Submit the invocation's admitted composer images as one model-visible user + * message ahead of the goal's next round. The images precede a fixed text + * block naming their role, so a later goal round reads them from ordinary + * session history without the goal domain storing attachment state. + */ +function submitObjectiveAttachments(invocation: CommandInvocation): void { + if (invocation.attachments.length === 0) return + invocation.agent.followup(createUserMessage({ + content: [...invocation.attachments, { type: 'text', text: 'Reference images for the goal objective.' }], + source: { kind: 'user' }, + })) +} + /** Execute one parsed human command through the domain that owns persistence. */ function executeGoalCommand(ctx: Context, invocation: CommandInvocation): CommandResult { const command = parseGoalCommand(invocation.rawInput) + if (invocation.attachments.length > 0 && command.kind !== 'create' && command.kind !== 'edit') { + return { + kind: 'error', + text: 'Image attachments only accompany a goal objective: /goal or /goal edit .', + } + } try { const current = ctx.goals.get(invocation.agent) switch (command.kind) { @@ -118,23 +139,28 @@ function executeGoalCommand(ctx: Context, invocation: CommandInvocation): Comman : renderGoal('Goal', current) case 'invalid-edit': return { kind: 'error', text: `Goal editing requires a replacement objective.\n${USAGE}` } - case 'create': + case 'create': { if (current !== undefined && current.phase !== 'complete') { return { kind: 'error', text: `A goal is already ${phaseLabel(current.phase)}. Use /goal edit to change it or /goal clear before replacing it.`, } } - return renderGoal('Goal created', ctx.goals.create(invocation.agent, { objective: command.objective })) - case 'edit': + const created = ctx.goals.create(invocation.agent, { objective: command.objective }) + submitObjectiveAttachments(invocation) + return renderGoal('Goal created', created) + } + case 'edit': { if (current === undefined) return missingGoal('edit') if (current.phase === 'complete') { - return renderGoal('Goal created', ctx.goals.create(invocation.agent, { objective: command.objective })) + const replaced = ctx.goals.create(invocation.agent, { objective: command.objective }) + submitObjectiveAttachments(invocation) + return renderGoal('Goal created', replaced) } - return renderGoal( - 'Goal updated', - ctx.goals.edit(invocation.agent, goalRef(current), { objective: command.objective }), - ) + const edited = ctx.goals.edit(invocation.agent, goalRef(current), { objective: command.objective }) + submitObjectiveAttachments(invocation) + return renderGoal('Goal updated', edited) + } case 'pause': if (current === undefined) return missingGoal('pause') return renderGoal('Goal paused', ctx.goals.pause(invocation.agent, goalRef(current))) @@ -164,7 +190,7 @@ export function apply(ctx: Context): void { ctx.commands.register({ name: 'goal', description: 'set or view the goal for a long-running task', - input: { hint: '[|clear|edit |pause|resume]' }, + input: { hint: '[|clear|edit |pause|resume]', images: true }, handler: invocation => executeGoalCommand(ctx, invocation), }) } diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index cae48f9b92..2127844646 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -73,6 +73,7 @@ async function run(test: Harness, suffix = ''): Promise { expect(test.ctx.commands.list(test.agent)).toContainEqual({ name: 'goal', description: 'set or view the goal for a long-running task', - input: { hint: '[|clear|edit |pause|resume]' }, + input: { hint: '[|clear|edit |pause|resume]', images: true }, }) expect(test.ctx.commands.find(test.agent, 'goal')).toBeDefined() @@ -232,3 +233,99 @@ describe('/goal human command', () => { await expect(run(test)).rejects.toThrow('unexpected failure') }) }) + +describe('/goal image attachments', () => { + const PNG = 'AAAA' + + /** Wire the fake store the executor admits through (once per harness). */ + function provideStore(test: Harness): void { + let saved = 0 + const saveImage = (input: { mediaType: string; name?: string }) => { + saved += 1 + return Promise.resolve({ + attachmentId: `att-${saved}`, mediaType: input.mediaType, bytes: 3, width: 1, height: 1, + ...input.name === undefined ? {} : { name: input.name }, + }) + } + test.ctx.provide('attachments', { + imageLimits: { + maxImageBytes: 1024, maxImagesPerMessage: 4, maxMessageImageBytes: 1024, + maxImagePixels: 1_000_000, mediaTypes: ['image/png'], + }, + validateImage: () => Promise.resolve(), + saveImage, + async saveImages(inputs: readonly { mediaType: string; name?: string }[]) { + const refs = [] + for (const input of inputs) refs.push(await saveImage(input)) + return refs + }, + }) + } + + /** Run /goal with `count` composer images through the executor boundary. */ + async function runWithImages(test: Harness, suffix: string, count: number) { + const images = Array.from({ length: count }, (_, index) => ({ + mediaType: 'image/png' as const, data: PNG, name: `ref-${index + 1}.png`, + })) + const execution = await test.ctx.commands.execute(test.agent, `/goal${suffix}`, images, new AbortController().signal) + if (execution === undefined) throw new Error('goal command was not registered') + return execution.result + } + + it('submits one user followup carrying the admitted images ahead of the round prompt', async () => { + const test = await harness() + provideStore(test) + const followup = vi.fn() + ;(test.agent as unknown as { followup: typeof followup }).followup = followup + const result = await runWithImages(test, ' rebuild the cathedral', 2) + expect(result.kind).toBe('success') + expect(followup).toHaveBeenCalledTimes(1) + const message = followup.mock.calls[0]?.[0] as { + content: ReadonlyArray> + source: { kind: string } + } + expect(message.source).toEqual({ kind: 'user' }) + expect(message.content.map(block => block.type)).toEqual(['image', 'image', 'text']) + expect(message.content.at(-1)).toEqual({ type: 'text', text: 'Reference images for the goal objective.' }) + expect((message.content[0] as { attachment: { name: string } }).attachment.name).toBe('ref-1.png') + }) + + it('accompanies an edit and a post-complete recreate the same way', async () => { + const test = await harness() + provideStore(test) + const followup = vi.fn() + ;(test.agent as unknown as { followup: typeof followup }).followup = followup + test.ctx.goals.create(test.agent, { objective: 'initial objective' }) + const result = await runWithImages(test, ' edit refined objective', 1) + expect(result.kind).toBe('success') + expect(followup).toHaveBeenCalledTimes(1) + }) + + it('rejects attachments on sub-commands that cannot use them, leaving the domain untouched', async () => { + const test = await harness() + provideStore(test) + const followup = vi.fn() + ;(test.agent as unknown as { followup: typeof followup }).followup = followup + test.ctx.goals.create(test.agent, { objective: 'active objective' }) + for (const suffix of [' pause', '', ' clear']) { + const result = await runWithImages(test, suffix, 1) + expect(result).toEqual({ + kind: 'error', + text: 'Image attachments only accompany a goal objective: /goal or /goal edit .', + }) + } + expect(followup).not.toHaveBeenCalled() + expect(test.ctx.goals.get(test.agent)?.phase).toBe('active') + }) + + it('does not submit attachments when goal creation is refused', async () => { + const test = await harness() + provideStore(test) + const followup = vi.fn() + ;(test.agent as unknown as { followup: typeof followup }).followup = followup + test.ctx.goals.create(test.agent, { objective: 'existing objective' }) + const result = await runWithImages(test, ' replacement objective', 1) + expect(result.kind).toBe('error') + expect(followup).not.toHaveBeenCalled() + }) +}) diff --git a/packages/goal/command-goal/tsconfig.json b/packages/goal/command-goal/tsconfig.json index 0be94e6ef3..bfd4de65c5 100644 --- a/packages/goal/command-goal/tsconfig.json +++ b/packages/goal/command-goal/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../interaction/commands" }, + { + "path": "../../llm/llm" + }, { "path": "../goal" }, diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index c675b60907..ab8f6aa127 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -10,7 +10,7 @@ import type { Context } from '@deepseek-ai/cordis' import { installModelSelection } from '@deepseek-ai/dsh-agent' import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-presets/types' -import { AttachmentError } from '@deepseek-ai/dsh-attachment' +import { AttachmentError, admitEncodedImages } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { contentHasImage, createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { errorChain } from '@deepseek-ai/dsh-llm' @@ -123,42 +123,17 @@ export const DEFAULT_COLD_BLANK_PROBE_MAX_BYTES = 1024 /** Conversation message event types (the pagination counting unit). */ const MESSAGE_TYPES = new Set(['user/message', 'assistant/message']) -/** Decode the browser payload while rejecting non-canonical base64 forms. */ -function decodeBase64(data: string): Uint8Array { - const decoded = Buffer.from(data, 'base64') - if (data.length === 0 || decoded.toString('base64') !== data) { - throw new AttachmentError('Image upload is not canonical base64.', 'INVALID_IMAGE_BASE64') - } - return new Uint8Array(decoded) -} - /** Validate one prompt as a batch before publishing any durable image object. */ async function durablePromptContent(ctx: Context, content: readonly PromptContentPart[]): Promise { if (content.every(part => part.type === 'text')) { return content.map(part => ({ type: 'text', text: part.text })) } - const prepared = content.map(part => part.type === 'text' - ? part - : { part, data: decodeBase64(part.data) }) - const images = prepared.filter((part): part is Extract => 'data' in part) - const refs = await ctx.attachments.saveImages(images.map(image => ({ - data: image.data, - mediaType: image.part.mediaType, - ...image.part.name === undefined ? {} : { name: image.part.name }, - }))) - const blocks: ContentBlock[] = [] - let imageIndex = 0 - for (const item of prepared) { - if (!('data' in item)) { - blocks.push({ type: 'text', text: item.text }) - continue - } - const attachment = refs[imageIndex++] - /* v8 ignore next -- each prepared image supplied exactly one saveImages input and therefore one ordered ref. */ - if (attachment === undefined) throw new Error('attachment batch result did not preserve input cardinality') - blocks.push({ type: 'image', attachment }) - } - return blocks + const refs = await admitEncodedImages(ctx.attachments, content.filter(part => part.type === 'image')) + let next = 0 + return content.map(part => part.type === 'text' + ? { type: 'text', text: part.text } + // admitEncodedImages returns one reference per image part in order. + : { type: 'image', attachment: refs[next++] as ImageAttachmentRef }) } /** Search durable content for an image reference, including nested tool results. */ diff --git a/packages/interaction/commands/README.i18n.yaml b/packages/interaction/commands/README.i18n.yaml index 9a53413b85..fa8ce400f3 100644 --- a/packages/interaction/commands/README.i18n.yaml +++ b/packages/interaction/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/interaction/commands/README.md -README.md: e1d7e5f4f626d062a16840bce6354ee3aac921c9 -README.zh.md: 6ce3ca9016e26537003ba85e0b2217bd37448fc5 +README.md: 4a4cb2a70b56ba1a18e9f4719541a50a9683510c +README.zh.md: f89ccd1a5cc9189b2810026481d4c57855481111 diff --git a/packages/interaction/commands/README.md b/packages/interaction/commands/README.md index e1d7e5f4f6..4a4cb2a70b 100644 --- a/packages/interaction/commands/README.md +++ b/packages/interaction/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, 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. +`ctx.commands.register(definition)` registers one lowercase command name, description, optional unstructured-input descriptor (`hint` plus an `images` flag declaring whether composer image attachments may accompany an invocation), 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, the issuing `CommandSource`, and `args` unless `recordInput` is false) and `command/done` (at settlement, with the outcome kind and verbatim text; a successful result may also name an earlier non-command authoritative domain event through `sourceEventSeq`; 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 (the descriptor carries `input.images` so composers can refuse image submissions to non-declaring commands before dispatch). `find(agent, name)` returns the corresponding definition. `execute(agent, line, images, 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. `images` carries the submission's base64-encoded composer images (`EncodedImageAttachment` from `@deepseek-ai/dsh-attachment/types`); the executor enforces the declaration — images sent to a non-declaring command, an absent `attachments` store, or an exceeded batch limit each settle as an error result before the handler runs, and a rejected batch publishes no durable object. An admitted batch is committed through `admitEncodedImages` and handed to the handler as frozen ordered `ImageBlock`s on `invocation.attachments`; the handler owns their model-visible use and returns an error when its grammar cannot use them, so the dispatching composer keeps the originals. 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 successful result may also name an earlier non-command authoritative domain event through `sourceEventSeq`; 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. @@ -24,7 +24,7 @@ The shipped `dsh` base mounts this service and the Web client dispatches through #### What the model sees -The registry itself submits nothing. Known slash commands execute in the UI command plane, and their `CommandResult` text is not submitted as a user message. Unknown slash-command input is rejected by shipped adapters instead of becoming a model prompt. A command producer may explicitly use the receiving `Agent`; for example, [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-interactions) submits the optional message in `/plan [message]` after selecting plan mode. +The registry itself submits nothing. Known slash commands execute in the UI command plane, and their `CommandResult` text is not submitted as a user message. Unknown slash-command input is rejected by shipped adapters instead of becoming a model prompt. A command producer may explicitly use the receiving `Agent`; for example, [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-interactions) submits the optional message in `/plan [message]` after selecting plan mode. Image attachments follow the same rule: the executor only admits them into durable attachment objects, and a declaring producer decides whether and how they become model-visible message content. #### Token effect diff --git a/packages/interaction/commands/README.zh.md b/packages/interaction/commands/README.zh.md index 6ce3ca9016..f89ccd1a5c 100644 --- a/packages/interaction/commands/README.zh.md +++ b/packages/interaction/commands/README.zh.md @@ -6,9 +6,9 @@ ## 服务约定 -`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` 观察者,使运行中的适配器能够刷新发现结果。观察者失败会写入日志,既不能否决注册表变更,也不能阻止后续观察者运行。 +`ctx.commands.register(definition)` 注册一个小写命令名称、描述、可选的非结构化输入描述符(`hint`,以及声明调用是否可携带 composer 图片附件的 `images` 标志)、可选的 `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`、解析器的结构化名称、发起方 `CommandSource`,以及 `args`(`recordInput` 为 false 时省略))与 `command/done`(结算时记录,携带结果类型与原样文本;成功结果还可通过 `sourceEventSeq` 指向更早的一条非命令权威领域事件;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。两者都直接独立追加到接收 agent 的会话中:没有轮次包裹它们,持久化机制会在常规检查点和销毁期间排空这些事件。 +`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符(描述符携带 `input.images`,使 composer 能在分发前就拒绝把图片提交给未声明的命令)。`find(agent, name)` 返回相应定义。`execute(agent, line, images, signal)` 使用 `parseCommand()`,且只运行已知命令,返回已结算的 `CommandExecution`(规范化结果加生命周期配对 `commandId`);语法无效或名称未知时返回 `undefined`。`images` 携带本次提交的 base64 编码 composer 图片(来自 `@deepseek-ai/dsh-attachment/types` 的 `EncodedImageAttachment`);执行器负责声明的强制执行:把图片发给未声明的命令、`attachments` 存储缺失、或批量超出限制,都会在处理器运行前以错误结果结算,被拒绝的批量不会发布任何持久化对象。通过准入的批量经 `admitEncodedImages` 提交,并以冻结的有序 `ImageBlock` 数组挂在 `invocation.attachments` 上交给处理器;处理器负责它们的模型可见用途,当其语法无法使用这些图片时返回错误,使分发方 composer 保留原件。已解析命令的生命周期会以 log-only 事件对的形式记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带新生成的 `commandId`、解析器的结构化名称、发起方 `CommandSource`,以及 `args`(`recordInput` 为 false 时省略))与 `command/done`(结算时记录,携带结果类型与原样文本;成功结果还可通过 `sourceEventSeq` 指向更早的一条非命令权威领域事件;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。两者都直接独立追加到接收 agent 的会话中:没有轮次包裹它们,持久化机制会在常规检查点和销毁期间排空这些事件。 `parseCommand()` 识别位于第 0 字节的斜杠、由小写字母、数字、`_` 或 `-` 构成的名称,以及名称后紧接输入末尾或空白的形式。它将名称后的每个字节作为 `rawInput` 返回,其中包括分隔空白;消费方负责各命令专用的语法,只能执行该语法允许的规范化。 @@ -24,7 +24,7 @@ #### 模型看到的内容 -注册表自身不会提交任何内容。已知斜杠命令在 UI 命令平面执行,其 `CommandResult` 文本不会作为用户消息提交。已交付的适配器会拒绝未知斜杠命令输入,而不是将其变成模型提示词。命令生产方可以显式使用接收命令的 `Agent`;例如,[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-interactions)在选择 plan mode 后,会提交 `/plan [message]` 中的可选消息。 +注册表自身不会提交任何内容。已知斜杠命令在 UI 命令平面执行,其 `CommandResult` 文本不会作为用户消息提交。已交付的适配器会拒绝未知斜杠命令输入,而不是将其变成模型提示词。命令生产方可以显式使用接收命令的 `Agent`;例如,[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-interactions)在选择 plan mode 后,会提交 `/plan [message]` 中的可选消息。图片附件遵循同一规则:执行器只负责把它们准入为持久化附件对象,是否以及如何成为模型可见的消息内容由声明接受的生产方决定。 #### Token 影响 diff --git a/packages/interaction/commands/package.json b/packages/interaction/commands/package.json index 0fb8ab6650..84e2d41b88 100644 --- a/packages/interaction/commands/package.json +++ b/packages/interaction/commands/package.json @@ -54,8 +54,10 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", @@ -66,8 +68,10 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", diff --git a/packages/interaction/commands/src/index.ts b/packages/interaction/commands/src/index.ts index 4e179435bb..9e078938ed 100644 --- a/packages/interaction/commands/src/index.ts +++ b/packages/interaction/commands/src/index.ts @@ -5,6 +5,9 @@ import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' +import { AttachmentError, admitEncodedImages } from '@deepseek-ai/dsh-attachment' +import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types' +import type { ImageBlock } from '@deepseek-ai/dsh-llm' import { NamedEntries, ScopedLayers } from '@deepseek-ai/dsh-scope' import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope' import type { Session, SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session' @@ -24,6 +27,9 @@ export const name = 'commands' const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u +/** Shared frozen attachments value for image-free invocations. */ +const NO_ATTACHMENTS: readonly ImageBlock[] = Object.freeze([]) + /** Invocation passed to one registered command handler. */ export interface CommandInvocation { /** Pairing id already written to this invocation's `command/run` event. */ @@ -32,6 +38,14 @@ export interface CommandInvocation { readonly agent: Agent /** Exact text following the registered command name, including separator whitespace. */ readonly rawInput: string + /** + * Durably admitted image blocks accompanying this invocation, in submission + * order; empty unless the definition declares `input.images`. The handler + * owns their model-visible use — the registry never schedules them itself — + * and a handler whose grammar cannot use them in this invocation returns an + * error so the dispatching composer retains the originals. + */ + readonly attachments: readonly ImageBlock[] /** Cancellation signal owned by the dispatching UI request. */ readonly signal: AbortSignal } @@ -114,6 +128,11 @@ function abortError(signal: AbortSignal): Error { return new Error(typeof signal.reason === 'string' ? signal.reason : 'command aborted') } +/** The signal's normalized abort error when it is already aborted. */ +function cancellationOf(signal: AbortSignal): Error | undefined { + return signal.aborted ? abortError(signal) : undefined +} + /** Render arbitrary thrown values without trusting their string coercion. */ function renderThrown(value: unknown): string { try { @@ -171,7 +190,13 @@ function normalizeDefinition(definition: CommandDefinition): RegisteredCommand { if (rawInput.hint.trim().length === 0) { throw new TypeError(`command "${definition.name}" input hint must not be empty`) } - input = Object.freeze({ hint: rawInput.hint }) + if ('images' in rawInput && rawInput.images !== undefined && typeof rawInput.images !== 'boolean') { + throw new TypeError(`command "${definition.name}" input images flag must be a boolean`) + } + input = Object.freeze({ + hint: rawInput.hint, + ...('images' in rawInput && rawInput.images === true) ? { images: true } : {}, + }) } const normalized = Object.freeze({ name: definition.name, @@ -287,8 +312,15 @@ export class CommandRuntime extends TypertRemoteService { * handler-failure path is contained so the handler's own error stays the * reported failure. * + * Image admission is enforced here, not in the composer: images sent to a + * command that does not declare `input.images`, an absent attachment store, + * and an exceeded attachment limit each settle as an error result before + * the handler runs, and a rejected batch publishes no durable object. + * * @param agent - exact receiving agent. * @param line - complete slash-command line. + * @param images - base64-encoded composer images accompanying the line, in + * submission order; empty for a plain invocation. * @param signal - cancellation signal owned by the UI request. * @returns the settled execution (result + lifecycle pairing id), or * `undefined` when syntax or name does not resolve. @@ -297,6 +329,7 @@ export class CommandRuntime extends TypertRemoteService { async execute( agent: Agent, line: string, + images: readonly EncodedImageAttachment[], signal: AbortSignal, ): Promise { const parsed = parseCommand(line) @@ -311,30 +344,67 @@ export class CommandRuntime extends TypertRemoteService { ...command.definition.recordInput === false ? {} : { args: parsed.rawInput }, source: { kind: 'user' }, }) - const invocation = Object.freeze({ commandId, agent, rawInput: parsed.rawInput, signal }) + const settle = (result: CommandResult): CommandExecution => { + this.appendLifecycle(agent.session, 'command/done', { + commandId, kind: result.kind, + ...result.text === undefined ? {} : { text: result.text }, + ...result.kind === 'success' && result.sourceEventSeq !== undefined + ? { sourceEventSeq: result.sourceEventSeq } + : {}, + }) + return Object.freeze({ commandId, result: Object.freeze(result) }) + } + let attachments: readonly ImageBlock[] = NO_ATTACHMENTS + if (images.length > 0) { + if (command.definition.input?.images !== true) { + return settle({ kind: 'error', text: `/${parsed.name} does not accept image attachments` }) + } + const store = this.ctx.get('attachments') + if (store === undefined) { + return settle({ kind: 'error', text: `/${parsed.name}: image attachments are unavailable because no attachment store is composed` }) + } + try { + const refs = await admitEncodedImages(store, images) + attachments = Object.freeze(refs.map(ref => Object.freeze({ type: 'image' as const, attachment: ref }))) + } catch (error: unknown) { + if (error instanceof AttachmentError) { + return settle({ kind: 'error', text: error.message }) + } + this.settleThrown(agent.session, parsed.name, commandId, error) + throw error + } + // Cancellation must be honored BEFORE the handler runs: admission may + // await slow storage, and a handler entered after the caller cancelled + // would mutate state the retrying caller then duplicates. (The committed + // image objects stay unreferenced and are deferred-GC territory.) + const cancelledDuringAdmission = cancellationOf(signal) + if (cancelledDuringAdmission !== undefined) { + this.settleThrown(agent.session, parsed.name, commandId, cancelledDuringAdmission) + throw cancelledDuringAdmission + } + } + const invocation = Object.freeze({ commandId, agent, rawInput: parsed.rawInput, attachments, signal }) let result: CommandResult try { const output = command.definition.handler(invocation) result = normalizeResult(parsed.name, await withAbort(Promise.resolve(output), signal)) } catch (error: unknown) { - try { - this.appendLifecycle(agent.session, 'command/done', { - commandId, kind: 'error', - text: error instanceof Error ? error.message : renderThrown(error), - }) - } catch (appendError: unknown) { - this.ctx.logger.warn(`command "${parsed.name}": command/done append failed: ${renderThrown(appendError)}`) - } + this.settleThrown(agent.session, parsed.name, commandId, error) throw error } - this.appendLifecycle(agent.session, 'command/done', { - commandId, kind: result.kind, - ...result.text === undefined ? {} : { text: result.text }, - ...result.kind === 'success' && result.sourceEventSeq !== undefined - ? { sourceEventSeq: result.sourceEventSeq } - : {}, - }) - return Object.freeze({ commandId, result }) + return settle(result) + } + + /** Contained `command/done` error append for a thrown handler or admission failure. */ + private settleThrown(session: Session, command: string, commandId: CommandId, error: unknown): void { + try { + this.appendLifecycle(session, 'command/done', { + commandId, kind: 'error', + text: error instanceof Error ? error.message : renderThrown(error), + }) + } catch (appendError: unknown) { + this.ctx.logger.warn(`command "${command}": command/done append failed: ${renderThrown(appendError)}`) + } } /** Mint the next pairing id (monotonic; instance-token-prefixed so a resumed log never repeats one). */ diff --git a/packages/interaction/commands/src/types.ts b/packages/interaction/commands/src/types.ts index 32f1dbcc43..f8e375774d 100644 --- a/packages/interaction/commands/src/types.ts +++ b/packages/interaction/commands/src/types.ts @@ -13,6 +13,14 @@ import type { CommandId } from './brand.ts' export interface CommandInputDescriptor { /** Placeholder shown before the user supplies free-form input. */ readonly hint: string + /** + * Whether composer image attachments may accompany an invocation. Absent or + * false = the executor rejects an invocation carrying images and capable + * composers refuse the submission before dispatch. A declaring command's + * handler receives the admitted durable blocks and owns every further + * grammar decision, including rejecting sub-commands that cannot use them. + */ + readonly images?: boolean } /** Expected command outcome rendered directly by the dispatching UI. */ diff --git a/packages/interaction/commands/tests/commands.spec.ts b/packages/interaction/commands/tests/commands.spec.ts index c00f938cac..755bb0ab2f 100644 --- a/packages/interaction/commands/tests/commands.spec.ts +++ b/packages/interaction/commands/tests/commands.spec.ts @@ -5,6 +5,7 @@ import type { Scope } from '@deepseek-ai/dsh-scope' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import CommandRuntime, { parseCommand, type CommandDefinition } from '@deepseek-ai/dsh-commands' +import { AttachmentStore } from '@deepseek-ai/dsh-attachment' function command(name: string, text = `ran:${name}`): CommandDefinition { return { @@ -96,11 +97,11 @@ describe('CommandRuntime', () => { expect(ctx.commands.list(agent).map(item => item.name)).toEqual(['shared']) expect(ctx.commands.find(agent, 'shared')?.handler).toBeDefined() expect(ctx.commands.list(other).map(item => item.name)).toEqual(['shared']) - expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.result) + expect((await ctx.commands.execute(agent, '/shared', [], new AbortController().signal))?.result) .toEqual({ kind: 'success', text: 'scoped' }) await scope.dispose() - expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.result.text).toBe('global') + expect((await ctx.commands.execute(agent, '/shared', [], new AbortController().signal))?.result.text).toBe('global') }) it('removes a registration when its contributing plugin fiber is disposed', async () => { @@ -176,7 +177,7 @@ describe('CommandRuntime', () => { ctx.commands.register({ name: 'run', description: 'Run it', handler: seen }) const controller = new AbortController() - const execution = await ctx.commands.execute(agent, '/run untouched ', controller.signal) + const execution = await ctx.commands.execute(agent, '/run untouched ', [], controller.signal) expect(execution?.result).toEqual({ kind: 'success', text: 'ok' }) expect(execution?.commandId).toBeTruthy() @@ -187,8 +188,8 @@ describe('CommandRuntime', () => { rawInput: ' untouched ', signal: controller.signal, })) - await expect(ctx.commands.execute(agent, 'run', controller.signal)).resolves.toBeUndefined() - await expect(ctx.commands.execute(agent, '/missing', controller.signal)).resolves.toBeUndefined() + await expect(ctx.commands.execute(agent, 'run', [], controller.signal)).resolves.toBeUndefined() + await expect(ctx.commands.execute(agent, '/missing', [], controller.signal)).resolves.toBeUndefined() }) it('stops awaiting an aborted handler and handles an already-aborted signal', async () => { @@ -201,18 +202,18 @@ describe('CommandRuntime', () => { handler: () => new Promise((resolve) => { release = resolve }), }) const running = new AbortController() - const promise = ctx.commands.execute(agent, '/wait', running.signal) + const promise = ctx.commands.execute(agent, '/wait', [], running.signal) running.abort('operator cancelled command') await expect(promise).rejects.toThrow('operator cancelled command') release({ kind: 'success', text: 'late' }) const already = new AbortController() already.abort(new Error('already gone')) - await expect(ctx.commands.execute(agent, '/wait', already.signal)).rejects.toThrow('already gone') + await expect(ctx.commands.execute(agent, '/wait', [], already.signal)).rejects.toThrow('already gone') const defaultReason = new AbortController() defaultReason.abort({ source: 'test' }) - await expect(ctx.commands.execute(agent, '/wait', defaultReason.signal)).rejects.toThrow('command aborted') + await expect(ctx.commands.execute(agent, '/wait', [], defaultReason.signal)).rejects.toThrow('command aborted') }) it('propagates an asynchronously rejected handler', async () => { @@ -223,7 +224,7 @@ describe('CommandRuntime', () => { description: 'Reject', handler: () => Promise.reject(new Error('handler rejected')), }) - await expect(ctx.commands.execute(agent, '/reject', new AbortController().signal)) + await expect(ctx.commands.execute(agent, '/reject', [], new AbortController().signal)) .rejects.toThrow('handler rejected') ctx.commands.register({ @@ -232,7 +233,7 @@ describe('CommandRuntime', () => { // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercise untyped plugin normalization handler: () => Promise.reject('not an Error'), }) - await expect(ctx.commands.execute(agent, '/reject-value', new AbortController().signal)) + await expect(ctx.commands.execute(agent, '/reject-value', [], new AbortController().signal)) .rejects.toThrow('command handler rejected with a non-Error value: not an Error') const hostile = { toString(): string { throw new Error('cannot render') } } @@ -242,7 +243,7 @@ describe('CommandRuntime', () => { // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercise hostile plugin normalization handler: () => Promise.reject(hostile), }) - await expect(ctx.commands.execute(agent, '/reject-hostile', new AbortController().signal)) + await expect(ctx.commands.execute(agent, '/reject-hostile', [], new AbortController().signal)) .rejects.toMatchObject({ message: 'command handler rejected with a non-Error value: ', cause: hostile, @@ -261,7 +262,7 @@ describe('CommandRuntime', () => { return { kind: 'success' } }, }) - await expect(ctx.commands.execute(agent, '/self-abort', controller.signal)) + await expect(ctx.commands.execute(agent, '/self-abort', [], controller.signal)) .rejects.toThrow('aborted in handler') }) @@ -273,7 +274,7 @@ describe('CommandRuntime', () => { description: 'Denied', handler: () => ({ kind: 'error', text: 'not now' }), }) - const execution = await ctx.commands.execute(agent, '/denied', new AbortController().signal) + const execution = await ctx.commands.execute(agent, '/denied', [], new AbortController().signal) expect(execution?.result).toEqual({ kind: 'error', text: 'not now' }) expect(Object.isFrozen(execution?.result)).toBe(true) @@ -282,7 +283,7 @@ describe('CommandRuntime', () => { description: 'No output', handler: () => ({ kind: 'success' }), }) - const silent = await ctx.commands.execute(agent, '/silent', new AbortController().signal) + const silent = await ctx.commands.execute(agent, '/silent', [], new AbortController().signal) expect(silent?.result).toEqual({ kind: 'success' }) expect(Object.isFrozen(silent?.result)).toBe(true) }) @@ -302,7 +303,7 @@ describe('CommandRuntime', () => { const { agent } = await mintAgentScope(ctx, 'a') ctx.commands.register(command('deploy', 'deployed')) - const execution = await ctx.commands.execute(agent, '/deploy now', new AbortController().signal) + const execution = await ctx.commands.execute(agent, '/deploy now', [], new AbortController().signal) const lifecycle = lifecycleOf(agent) expect(lifecycle).toMatchObject([ @@ -330,7 +331,7 @@ describe('CommandRuntime', () => { handler: () => ({ kind: 'success', text: 'linked', sourceEventSeq: source.seq }), }) - const execution = await ctx.commands.execute(agent, '/linked', new AbortController().signal) + const execution = await ctx.commands.execute(agent, '/linked', [], new AbortController().signal) expect(execution?.result).toEqual({ kind: 'success', text: 'linked', sourceEventSeq: source.seq }) expect(lifecycleOf(agent)).toMatchObject([ @@ -350,7 +351,7 @@ describe('CommandRuntime', () => { handler: seen, }) - await ctx.commands.execute(agent, '/private keep this once', new AbortController().signal) + 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') @@ -363,8 +364,8 @@ describe('CommandRuntime', () => { const { agent } = await mintAgentScope(ctx, 'a') ctx.commands.register(command('first')) ctx.commands.register(command('second')) - await ctx.commands.execute(agent, '/first', new AbortController().signal) - await ctx.commands.execute(agent, '/second', new AbortController().signal) + await ctx.commands.execute(agent, '/first', [], new AbortController().signal) + await ctx.commands.execute(agent, '/second', [], new AbortController().signal) const ids = lifecycleOf(agent) .filter(event => event.type === 'command/run') .map(event => (event.data as { commandId: string }).commandId) @@ -375,7 +376,7 @@ describe('CommandRuntime', () => { const ctx = await mount() const { agent } = await mintAgentScope(ctx, 'a') ctx.commands.register({ name: 'denied', description: 'Denied', handler: () => ({ kind: 'error', text: 'not now' }) }) - await ctx.commands.execute(agent, '/denied', new AbortController().signal) + await ctx.commands.execute(agent, '/denied', [], new AbortController().signal) expect(lifecycleOf(agent)).toMatchObject([ { type: 'command/run', data: { name: 'denied' } }, { type: 'command/done', data: { kind: 'error', text: 'not now' } }, @@ -390,7 +391,7 @@ describe('CommandRuntime', () => { description: 'Throw', handler: () => { throw new Error('handler exploded') }, }) - await expect(ctx.commands.execute(agent, '/boom', new AbortController().signal)) + await expect(ctx.commands.execute(agent, '/boom', [], new AbortController().signal)) .rejects.toThrow('handler exploded') expect(lifecycleOf(agent)).toMatchObject([ { type: 'command/run', data: { name: 'boom' } }, @@ -407,7 +408,7 @@ describe('CommandRuntime', () => { handler: () => new Promise(() => undefined), }) const controller = new AbortController() - const pending = ctx.commands.execute(agent, '/hang', controller.signal) + const pending = ctx.commands.execute(agent, '/hang', [], controller.signal) // The run append must land before the abort so the pair stays complete. await vi.waitFor(() => { expect(lifecycleOf(agent)).toHaveLength(1) }) controller.abort('operator cancelled command') @@ -425,8 +426,8 @@ describe('CommandRuntime', () => { const { agent } = await mintAgentScope(ctx, 'a') ctx.commands.register(command('real')) const signal = new AbortController().signal - await ctx.commands.execute(agent, 'not a command', signal) - await ctx.commands.execute(agent, '/missing', signal) + await ctx.commands.execute(agent, 'not a command', [], signal) + await ctx.commands.execute(agent, '/missing', [], signal) expect(agent.session.events).toEqual([]) }) @@ -435,7 +436,7 @@ describe('CommandRuntime', () => { const { agent } = await mintAgentScope(ctx, 'a') ctx.commands.register(command('mid')) agent.session.append('turn/start', { turn: 1 }) - await ctx.commands.execute(agent, '/mid', new AbortController().signal) + await ctx.commands.execute(agent, '/mid', [], new AbortController().signal) expect(agent.session.events.map(event => event.type)).toEqual([ 'turn/start', 'command/run', 'command/done', ]) @@ -460,6 +461,159 @@ describe('CommandRuntime', () => { description: 'Broken', handler: () => output as never, }) - await expect(ctx.commands.execute(agent, '/broken', new AbortController().signal)).rejects.toThrow(expected) + await expect(ctx.commands.execute(agent, '/broken', [], new AbortController().signal)).rejects.toThrow(expected) + }) +}) + +describe('image attachments', () => { + const PNG = 'AAAA' + + function storeOf() { + let saved = 0 + const store = { + imageLimits: { + maxImageBytes: 1024, maxImagesPerMessage: 2, maxMessageImageBytes: 1024, + maxImagePixels: 1_000_000, maxImageDimension: 2000, mediaTypes: ['image/png'], + }, + validateImage: vi.fn(() => Promise.resolve()), + saveImage: vi.fn((input: { mediaType: string; name?: string }) => { + saved += 1 + return Promise.resolve({ + attachmentId: `att-${saved}`, mediaType: input.mediaType, bytes: 3, width: 1, height: 1, + ...input.name === undefined ? {} : { name: input.name }, + }) + }), + // The real base-class batch method over this double's limits and members. + saveImages(inputs: readonly unknown[]) { + return (AttachmentStore.prototype.saveImages as (this: unknown, batch: readonly unknown[]) => Promise).call(this, inputs) + }, + } + return store + } + + function accepting(handler: CommandDefinition['handler']): CommandDefinition { + return { + name: 'vision', + description: 'accepts images', + input: { hint: '', images: true }, + handler, + } + } + + it('rejects a boolean-typed images flag violation at registration', async () => { + const ctx = await mount() + expect(() => ctx.commands.register({ + ...command('flag-type'), + input: { hint: 'x', images: 'yes' }, + } as unknown as CommandDefinition)).toThrow('command "flag-type" input images flag must be a boolean') + }) + + it('lists images acceptance on the descriptor and omits a false flag', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + ctx.commands.register(accepting(() => ({ kind: 'success' }))) + ctx.commands.register({ ...command('plain-input'), input: { hint: 'x', images: false } }) + const byName = new Map(ctx.commands.list(agent).map(descriptor => [descriptor.name, descriptor])) + expect(byName.get('vision')?.input).toEqual({ hint: '', images: true }) + expect(byName.get('plain-input')?.input).toEqual({ hint: 'x' }) + }) + + it('settles images sent to a non-declaring command as a logged error before the handler', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + const handler = vi.fn(() => ({ kind: 'success' as const })) + ctx.commands.register({ ...command('deploy'), handler }) + const execution = await ctx.commands.execute( + agent, '/deploy now', [{ mediaType: 'image/png', data: PNG }], new AbortController().signal) + expect(execution?.result).toEqual({ kind: 'error', text: '/deploy does not accept image attachments' }) + expect(handler).not.toHaveBeenCalled() + expect(lifecycleOf(agent)).toMatchObject([ + { type: 'command/run', data: { name: 'deploy' } }, + { type: 'command/done', data: { kind: 'error', text: '/deploy does not accept image attachments' } }, + ]) + }) + + it('settles a declaring command as a logged error when no attachment store is composed', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + ctx.commands.register(accepting(() => ({ kind: 'success' }))) + const execution = await ctx.commands.execute( + agent, '/vision x', [{ mediaType: 'image/png', data: PNG }], new AbortController().signal) + expect(execution?.result).toEqual({ + kind: 'error', + text: '/vision: image attachments are unavailable because no attachment store is composed', + }) + }) + + it('admits and hands the handler frozen ordered image blocks; plain invocations stay empty', async () => { + const ctx = await mount() + ctx.provide('attachments', storeOf()) + const { agent } = await mintAgentScope(ctx, 'a') + const seen = vi.fn((invocation: { attachments: readonly unknown[] }) => { + expect(Object.isFrozen(invocation.attachments)).toBe(true) + return { kind: 'success' as const } + }) + ctx.commands.register(accepting(seen)) + await ctx.commands.execute(agent, '/vision x', [ + { mediaType: 'image/png', data: PNG, name: 'a.png' }, + { mediaType: 'image/png', data: PNG, name: 'b.png' }, + ], new AbortController().signal) + const invocation = seen.mock.calls[0]?.[0] as { attachments: ReadonlyArray<{ type: string; attachment: { name?: string } }> } + expect(invocation.attachments.map(block => [block.type, block.attachment.name])).toEqual([ + ['image', 'a.png'], ['image', 'b.png'], + ]) + await ctx.commands.execute(agent, '/vision y', [], new AbortController().signal) + expect((seen.mock.calls[1]?.[0] as { attachments: readonly unknown[] }).attachments).toEqual([]) + }) + + it('settles an admission limit failure as a logged error result', async () => { + const ctx = await mount() + ctx.provide('attachments', storeOf()) + const { agent } = await mintAgentScope(ctx, 'a') + const handler = vi.fn(() => ({ kind: 'success' as const })) + ctx.commands.register(accepting(handler)) + const three = [1, 2, 3].map(() => ({ mediaType: 'image/png' as const, data: PNG })) + const execution = await ctx.commands.execute(agent, '/vision x', three, new AbortController().signal) + expect(execution?.result).toEqual({ kind: 'error', text: 'Image batch exceeds the configured image-count limit.' }) + expect(handler).not.toHaveBeenCalled() + expect(lifecycleOf(agent).at(-1)).toMatchObject({ type: 'command/done', data: { kind: 'error' } }) + }) + + it('honors a cancellation that lands during admission before entering the handler', async () => { + const ctx = await mount() + const controller = new AbortController() + const store = storeOf() + store.saveImage.mockImplementationOnce((input: { mediaType: string }) => { + controller.abort('operator cancelled during admission') + return Promise.resolve({ attachmentId: 'att-late', mediaType: input.mediaType, bytes: 3, width: 1, height: 1 }) + }) + ctx.provide('attachments', store) + const { agent } = await mintAgentScope(ctx, 'a') + const handler = vi.fn(() => ({ kind: 'success' as const })) + ctx.commands.register(accepting(handler)) + await expect(ctx.commands.execute( + agent, '/vision x', [{ mediaType: 'image/png', data: PNG }], controller.signal, + )).rejects.toThrow('operator cancelled during admission') + expect(handler).not.toHaveBeenCalled() + expect(lifecycleOf(agent).at(-1)).toMatchObject({ + type: 'command/done', + data: { kind: 'error', text: 'operator cancelled during admission' }, + }) + }) + + it('logs and rethrows a non-attachment admission failure', async () => { + const ctx = await mount() + const store = storeOf() + store.saveImage.mockRejectedValueOnce(new Error('disk gone')) + ctx.provide('attachments', store) + const { agent } = await mintAgentScope(ctx, 'a') + ctx.commands.register(accepting(() => ({ kind: 'success' }))) + await expect(ctx.commands.execute( + agent, '/vision x', [{ mediaType: 'image/png', data: PNG }], new AbortController().signal, + )).rejects.toThrow('disk gone') + expect(lifecycleOf(agent).at(-1)).toMatchObject({ + type: 'command/done', + data: { kind: 'error', text: 'disk gone' }, + }) }) }) diff --git a/packages/interaction/commands/tsconfig.json b/packages/interaction/commands/tsconfig.json index 0504815c60..7f7bfd9ac0 100644 --- a/packages/interaction/commands/tsconfig.json +++ b/packages/interaction/commands/tsconfig.json @@ -14,12 +14,18 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../attachment/attachment" + }, { "path": "../../core/agent" }, { "path": "../../core/scope" }, + { + "path": "../../llm/llm" + }, { "path": "../../core/session" }, diff --git a/packages/interaction/permission-presets/tests/projection.spec.ts b/packages/interaction/permission-presets/tests/projection.spec.ts index 2c068594cc..f9bba33958 100644 --- a/packages/interaction/permission-presets/tests/projection.spec.ts +++ b/packages/interaction/permission-presets/tests/projection.spec.ts @@ -90,7 +90,7 @@ describe('/permission command', () => { it('switches through permission.set and logs the lifecycle pair', async () => { const { ctx, session } = await harness() const { agent, inject } = await agentFor(ctx, session) - const execution = await ctx.commands.execute(agent, '/permission danger-full-access', new AbortController().signal) + const execution = await ctx.commands.execute(agent, '/permission danger-full-access', [], new AbortController().signal) expect(execution?.result).toEqual({ kind: 'success', text: 'preset danger-full-access' }) expect(ctx.permissionPresets.current(session.events)).toBe('danger-full-access') expect(inject.mock.calls[0]?.[0]).toMatchObject({ @@ -106,7 +106,7 @@ describe('/permission command', () => { it('reports the current preset and the table on bare invocation', async () => { const { ctx, session } = await harness() const { agent } = await agentFor(ctx, session) - const execution = await ctx.commands.execute(agent, '/permission', new AbortController().signal) + const execution = await ctx.commands.execute(agent, '/permission', [], new AbortController().signal) expect(execution?.result).toEqual({ kind: 'success', text: 'current preset workspace-write (available: workspace-write, danger-full-access)', @@ -119,7 +119,7 @@ describe('/permission command', () => { const { agent } = await agentFor(ctx, session) const before = session.events.filter(event => event.type !== 'command/run' && event.type !== 'command/done') - const execution = await ctx.commands.execute(agent, '/permission yolo', new AbortController().signal) + const execution = await ctx.commands.execute(agent, '/permission yolo', [], new AbortController().signal) // The error text carries the same no-self-labelling rule as the success // texts: `permission · unknown preset "yolo" (…)`, not `unknown permission // preset`, which the row's own title already says. diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml index fb59fa576d..c12c5c248f 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: 7171997406ea43487762d9947d07426df400c78a -README.zh.md: 5c04cdabc293c9abda6bb5e77f596715ee137c6f +README.md: 3eabe2cb3f04b434b7f908f7beca869f1022a59e +README.zh.md: f7d6a1f8e9f5ba95f8aad9457f3dde5fc415fdcf diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index 7171997406..3eabe2cb3f 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -16,13 +16,13 @@ While active, `plan:policy` renders the configured `section`. The plugin always The review question declares the `plan-review` presentation intent, naming `Approve` as the label that approves it, so a capable UI presents the plan as a decision instead of a generic question; the answer the tool reads is the same either way. A dismissed review — the user closing the request to speak instead — is reported to the model as such, telling it to stay in plan mode and wait for the message; every other review failure keeps the seam's own message. -When `ctx.commands` is composed, the package registers `/plan [message]` and reserves the exact argument `off` for direct exit. Bare `/plan` selects plan mode; any other non-empty argument selects it first and is then submitted through `agent.steer()`, so it becomes the next step's ordinary logged user message under plan guidance. `/plan off` selects inactive without sending model input; it also cancels a pending entry before plan mode reaches a request. +When `ctx.commands` is composed, the package registers `/plan [message]` and reserves the exact argument `off` for direct exit. Bare `/plan` selects plan mode; any other non-empty argument selects it first and is then submitted through `agent.steer()`, so it becomes the next step's ordinary logged user message under plan guidance. `/plan off` selects inactive without sending model input; it also cancels a pending entry before plan mode reaches a request. The command declares `input.images`: composer image attachments ride the steered message ahead of its text block. Bare `/plan` with images steers an image-only user message, while `/plan off` with images returns a direct error before any mode change so the composer keeps them. The Web client consumes the plugin-owned `/plan` command; other entry points may drive the same service directly without defining a second mode vocabulary. ## Session projection -When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session/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, so a failed handler cannot leave a recorded command without its plan selection). 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/session-projection/README.md)), this package registers the `plan` projection unit under an injected child. A `command/run` record named `plan` with recorded `args` starts a candidate target (`off` → inactive, anything else → active); its paired `command/done` retains a successful selection and drops an error; `plan/mode` commits the logged state and clears the retained selection. Every other event returns the same state reference. `view` derives `{ active, pending }`, where `pending` is true only while an unsettled or successful selection differs from the logged state. This remains a pure replay quantity, so host restarts, other tabs, and cold reads recover it from the log alone, and a rejected `/plan off` with images cannot leave a pending exit. 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 @@ -65,11 +65,11 @@ The section is stable within plan mode, but entering or leaving changes the syst #### What the model sees -`/plan`, `/plan off`, and their terminal results stay outside model history. A non-empty suffix other than the exact `off` argument becomes one trimmed user text block through `agent.steer()` after plan mode is selected. An active `/plan off` selection contributes the standard logged user-switch notice only when the last request header described plan mode; cancelling a pending entry contributes none because no request observed it. +`/plan`, `/plan off`, and their terminal results stay outside model history. A non-empty suffix other than the exact `off` argument becomes one user message through `agent.steer()` after plan mode is selected: any admitted image attachments as leading image blocks, then the trimmed text block. Bare `/plan` with admitted images steers one user message containing only those image blocks. An active `/plan off` selection contributes the standard logged user-switch notice only when the last request header described plan mode; cancelling a pending entry contributes none because no request observed it. #### Token effect -The optional message costs the same history tokens as submitting that text separately; bare `/plan` and `/plan off` add none. A narrated active exit adds the small retained switch notice. +The optional message costs the same history tokens as submitting that content separately. Bare `/plan` without images and `/plan off` add none; bare `/plan` with images has the normal image-prompt cost. A narrated active exit adds the small retained switch notice. #### KV Cache effect diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md index 5c04cdabc2..f7d6a1f8e9 100644 --- a/packages/plan/plan-mode/README.zh.md +++ b/packages/plan/plan-mode/README.zh.md @@ -16,13 +16,13 @@ 评审问题声明 `plan-review` 呈现意图,并指名 `Approve` 为表示批准的标签,因此有能力的 UI 会把计划呈现为一次决定而非通用问题;两种情况下该工具读到的回答完全相同。放弃审阅——用户关闭请求,转而发言——会如实报告给模型,要求它留在 plan mode 中等待那条消息;其余每一种评审失败都保留 seam 自身的消息。 -组合 `ctx.commands` 时,该包会注册 `/plan [message]`,并将参数恰好为 `off` 的情况保留给直接退出。不带参数的 `/plan` 会启用 plan mode;任何其他非空参数都会先启用 plan mode,再通过 `agent.steer()` 提交,因此它会在 plan 引导下成为下一步骤的常规已记录用户消息。`/plan off` 会选择停用状态,不发送模型输入;它还可以在启用 plan mode 的待处理选择由轮内 pre-step 追加之前将其取消。 +组合 `ctx.commands` 时,该包会注册 `/plan [message]`,并将参数恰好为 `off` 的情况保留给直接退出。不带参数的 `/plan` 会启用 plan mode;任何其他非空参数都会先启用 plan mode,再通过 `agent.steer()` 提交,因此它会在 plan 引导下成为下一步骤的常规已记录用户消息。`/plan off` 会选择停用状态,不发送模型输入;它还可以在启用 plan mode 的待处理选择由轮内 pre-step 追加之前将其取消。该命令声明了 `input.images`:composer 图片附件会随被 steer 的消息一起提交,位于文本块之前。不带参数的 `/plan` 若附有图片,会 steer 一条只含图片的用户消息;`/plan off` 若附有图片,会在任何模式变更前直接返回错误,composer 保留图片。 Web 客户端使用该插件提供的 `/plan` 命令;其他入口可以直接驱动同一服务,无需定义第二套 mode 词汇。 ## 会话投影 -当组合挂载 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md))时,本包会在一个注入的子插件中注册 `plan` 投影单元。该单元折叠两类事件:名为 `plan` 且携带已记录 `args` 的 `command/run` 记录会设置目标状态(`off` → 未激活,其余 → 激活),`plan/mode` 会提交已记录状态并清除该目标;其他任何事件都返回同一个状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在尚未落实的选择与已记录状态不同时为 true。该值完全由日志回放得出,因此 host 重启、其他标签页和冷读都能仅凭日志恢复它。`/plan` 处理器会在任何可能失败的路径之前调用 `set()`,因此处理器失败时不会留下缺少对应 plan 选择的已记录命令。key 由 `src/types.ts` 通过声明合并加入 `SessionProjectionMap`:host 消费方经 `./types` 获取,client 聚合经 `./client` 获取。框架负责驱动该单元,载体通过历史尾页和 `session/projection` 推送帧提供其值。未挂载注册表的组合不受影响。 +当组合挂载 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md))时,本包会在一个注入的子插件中注册 `plan` 投影单元。名为 `plan` 且携带已记录 `args` 的 `command/run` 记录会开始一个候选目标(`off` → 未激活,其余 → 激活);与它配对的 `command/done` 保留成功选择并丢弃错误选择;`plan/mode` 提交已记录状态并清除已保留的选择。其他任何事件都返回同一个状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在未结算或已成功的选择与已记录状态不同时为 true。该值仍完全由日志回放得出,因此 host 重启、其他标签页和冷读都能仅凭日志恢复它,被拒绝的带图 `/plan off` 也不会留下待退出状态。key 由 `src/types.ts` 通过声明合并加入 `SessionProjectionMap`:host 消费方经 `./types` 获取,client 聚合经 `./client` 获取。框架负责驱动该单元,载体通过历史尾页和 `session/projection` 推送帧提供其值。未挂载注册表的组合不受影响。 ## 配置 @@ -65,11 +65,11 @@ You are in plan mode. Explore and design before presenting the complete plan thr #### 模型所见内容 -`/plan`、`/plan off` 及其终端结果留在模型历史之外。除恰好为 `off` 以外的非空后缀会在选择 plan mode 后,通过 `agent.steer()` 成为一个已去除首尾空白的用户文本块。plan mode 已激活时,选择 `/plan off` 只会在最后一个请求头描述了 plan mode 的情况下追加标准的已记录用户切换通知;取消待生效进入不会贡献通知,因为没有请求观测到它。 +`/plan`、`/plan off` 及其终端结果留在模型历史之外。除恰好为 `off` 以外的非空后缀会在选择 plan mode 后,通过 `agent.steer()` 成为一条用户消息:任何已准入的图片附件作为前置图片块,之后是已去除首尾空白的文本块。不带参数的 `/plan` 若带有已准入图片,会 steer 一条只含这些图片块的用户消息。plan mode 已激活时,选择 `/plan off` 只会在最后一个请求头描述了 plan mode 的情况下追加标准的已记录用户切换通知;取消待生效进入不会贡献通知,因为没有请求观测到它。 #### Token 影响 -可选消息的历史 token 成本与单独提交该文本相同;不带参数的 `/plan` 和 `/plan off` 不增加 token。一次带有切换通知的已激活状态退出会追加一条简短且会保留的通知。 +可选消息的历史 token 成本与单独提交该内容相同。不带图片和参数的 `/plan` 与 `/plan off` 不增加 token;不带参数但带图的 `/plan` 产生常规图片提示词成本。一次带有切换通知的已激活状态退出会追加一条简短且会保留的通知。 #### KV Cache 影响 diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index d8399ee3e1..b3d0933256 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -34,6 +34,7 @@ import type {} from '@deepseek-ai/dsh-system-prompt' import { UserQuestionError } from '@deepseek-ai/dsh-user-questions' // Type-only edge: resolves `ctx.commands` for the optional command child. import type {} from '@deepseek-ai/dsh-commands' +import type { CommandId } from '@deepseek-ai/dsh-commands/brand' // Type-only: resolves ctx.sessionProjections for the optional unit child. import type {} from '@deepseek-ai/dsh-session-projection' import type { PlanProjection } from './types.ts' @@ -138,14 +139,17 @@ export function foldPlanMode(events: readonly SessionEvent[], end = events.lengt } /** - * Projection unit state: the logged mode plus the latest logged `/plan` - * selection (`command/run`) not yet resolved by a `plan/mode` commit. Plain - * JSON (persisted-cache precondition). + * Projection unit state: the logged mode, the latest successful `/plan` + * selection not yet resolved by a `plan/mode` commit, and an execution whose + * paired `command/done` has not settled. Plain JSON (persisted-cache + * precondition). */ interface PlanUnitState { active: boolean /** The selection's target mode; null when no selection is outstanding. */ wanted: boolean | null + /** The latest plan command awaiting its paired settlement. */ + running: { commandId: CommandId; wanted: boolean } | null } /** Wire payload schema of the `plan` projection. */ @@ -232,12 +236,11 @@ export class PlanModeController extends Service { }, }) - // The plan projection unit (session-projection RFC): a pure double-event - // fold serving clients the whole {active, pending} value. `command/run` - // records the user's logged /plan selection (the handler calls `set()` - // before any failing path, so a failed handler cannot leave the recorded - // command without its plan selection); `plan/mode` records that selection - // and clears it. Pending is thereby a pure + // The plan projection unit (session-projection RFC): a pure event fold + // serving clients the whole {active, pending} value. `command/run` + // records the user's logged /plan selection, its paired `command/done` + // keeps only successful selections, and `plan/mode` records that + // selection and clears it. Pending is thereby a pure // replay quantity: host restarts, other tabs, and cold reads all recover // it from the log alone. The unit child activates only when a projection // registry is composed (headless assemblies stay unaffected). @@ -245,23 +248,29 @@ export class PlanModeController extends Service { projectionCtx.sessionProjections.register<'plan', PlanUnitState>({ key: 'plan', schema: planProjectionSchema, - init: () => ({ active: false, wanted: null }), + init: () => ({ active: false, wanted: null, running: 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 } + return { ...state, running: { commandId: event.data.commandId, wanted } } + } + if (event.type === 'command/done' && event.data.commandId === state.running?.commandId) { + const wanted = event.data.kind === 'success' && state.running.wanted !== state.active + ? state.running.wanted + : null + return { ...state, wanted, running: null } } if (event.type === 'plan/mode') { - return { active: event.data.active, wanted: null } + return { ...state, active: event.data.active, wanted: null } } return state }, - view: state => ({ - active: state.active, - pending: state.wanted !== null && state.wanted !== state.active, - }), - stateVersion: 1, + view: (state) => { + const wanted = state.running?.wanted ?? state.wanted + return { active: state.active, pending: wanted !== null && wanted !== state.active } + }, + stateVersion: 2, }) }) @@ -270,9 +279,12 @@ export class PlanModeController extends Service { commandCtx.commands.register({ name: 'plan', description: 'Enter or leave plan mode', - input: { hint: '[off|message]' }, - handler: ({ agent, rawInput }) => { + input: { hint: '[off|message]', images: true }, + handler: ({ agent, rawInput, attachments }) => { const message = rawInput.trim() + if (message === 'off' && attachments.length > 0) { + return { kind: 'error', text: 'Image attachments cannot accompany /plan off.' } + } if (message === 'off') { switch (this.set(agent, false)) { case 'committed': @@ -291,7 +303,15 @@ export class PlanModeController extends Service { } } const outcome = this.set(agent, true) - if (message !== '') agent.steer(createUserMessage({ content: [{ type: 'text', text: message }], source: { kind: 'user' } })) + if (message !== '' || attachments.length > 0) { + agent.steer(createUserMessage({ + content: [ + ...attachments, + ...(message === '' ? [] : [{ type: 'text' as const, text: message }]), + ], + source: { kind: 'user' }, + })) + } return { kind: 'success', text: outcome === 'committed' diff --git a/packages/plan/plan-mode/src/types.ts b/packages/plan/plan-mode/src/types.ts index a3c10d2252..8efd32f22a 100644 --- a/packages/plan/plan-mode/src/types.ts +++ b/packages/plan/plan-mode/src/types.ts @@ -11,9 +11,10 @@ /** * The plan projection's wire value. `active` is the logged state in force * (the last `plan/mode`, inactive before the first); `pending` is true while - * a logged `/plan` selection (`command/run`) targets a state other than - * `active` and no later `plan/mode` event has recorded that state. Capability - * absence (plan-mode not composed) is the key's absence, never a value. + * a logged `/plan` selection targets a state other than `active`, has not + * failed through its paired `command/done`, and no later `plan/mode` event has + * recorded that state. Capability absence (plan-mode not composed) is the + * key's absence, never a value. */ export interface PlanProjection { active: boolean @@ -22,7 +23,7 @@ export interface PlanProjection { declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionMap { - /** Plan collaboration state folded from `command/run` (name `plan`) and `plan/mode` events. */ + /** Plan collaboration state folded from the plan command lifecycle and `plan/mode` events. */ plan: PlanProjection } } diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index c4d41b13d8..8285147953 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -561,13 +561,13 @@ describe('/plan', () => { const plainSteer = vi.fn() ;(plainAgent as unknown as { steer: typeof plainSteer }).steer = plainSteer expect(ctx.commands.list(plainAgent)).toEqual([ - { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]' } }, + { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', images: true } }, ]) const signal = new AbortController().signal - expect(await ctx.commands.execute(plainAgent, '/mode', signal)).toBeUndefined() - expect(await ctx.commands.execute(plainAgent, '/review', signal)).toBeUndefined() - const plain = await ctx.commands.execute(plainAgent, '/plan', signal) + expect(await ctx.commands.execute(plainAgent, '/mode', [], signal)).toBeUndefined() + expect(await ctx.commands.execute(plainAgent, '/review', [], signal)).toBeUndefined() + const plain = await ctx.commands.execute(plainAgent, '/plan', [], signal) expect(plain?.result).toEqual({ kind: 'success', text: 'Entering plan mode (applies from the next step). Use /plan off to leave.', @@ -579,7 +579,7 @@ describe('/plan', () => { openTurn(messageAgent.session) const messageSteer = vi.fn() ;(messageAgent as unknown as { steer: typeof messageSteer }).steer = messageSteer - const plan = await ctx.commands.execute(messageAgent, '/plan draft the migration ', signal) + const plan = await ctx.commands.execute(messageAgent, '/plan draft the migration ', [], signal) expect(plan?.result).toEqual({ kind: 'success', text: 'Entering plan mode (applies from the next step). Use /plan off to leave.', @@ -600,7 +600,7 @@ describe('/plan', () => { const signal = new AbortController().signal const inactive = await agentWithSession(ctx, 'inactive-plan-command') - expect((await ctx.commands.execute(inactive, '/plan off', signal))?.result) + expect((await ctx.commands.execute(inactive, '/plan off', [], signal))?.result) .toEqual({ kind: 'success', text: 'Plan mode is already inactive.' }) expect(ctx.planMode.get(inactive)).toEqual({ active: false }) @@ -608,8 +608,8 @@ describe('/plan', () => { openTurn(entering.session) const enteringSteer = vi.fn() ;(entering as unknown as { steer: typeof enteringSteer }).steer = enteringSteer - await ctx.commands.execute(entering, '/plan', signal) - expect((await ctx.commands.execute(entering, '/plan off', signal))?.result) + await ctx.commands.execute(entering, '/plan', [], signal) + expect((await ctx.commands.execute(entering, '/plan off', [], signal))?.result) .toEqual({ kind: 'success', text: 'Plan mode entry cancelled.' }) expect(ctx.planMode.get(entering)).toEqual({ active: false, pending: false }) expect(enteringSteer).not.toHaveBeenCalled() @@ -621,10 +621,10 @@ describe('/plan', () => { openTurn(active.session) const activeSteer = vi.fn() ;(active as unknown as { steer: typeof activeSteer }).steer = activeSteer - expect((await ctx.commands.execute(active, '/plan off', signal))?.result) + expect((await ctx.commands.execute(active, '/plan off', [], signal))?.result) .toEqual({ kind: 'success', text: 'Leaving plan mode (applies from the next step).' }) expect(ctx.planMode.get(active)).toEqual({ active: true, pending: false }) - expect((await ctx.commands.execute(active, '/plan off', signal))?.result) + expect((await ctx.commands.execute(active, '/plan off', [], signal))?.result) .toEqual({ kind: 'success', text: 'Leaving plan mode (applies from the next step).' }) expect(activeSteer).not.toHaveBeenCalled() await boundary(ctx, active, 'step-start') @@ -637,14 +637,80 @@ describe('/plan', () => { await new Promise(resolve => setImmediate(resolve)) const signal = new AbortController().signal const agent = await agentWithSession(ctx, 'idle-plan-command') - expect((await ctx.commands.execute(agent, '/plan', signal))?.result) + expect((await ctx.commands.execute(agent, '/plan', [], signal))?.result) .toEqual({ kind: 'success', text: 'Plan mode on. Use /plan off to leave.' }) expect(foldPlanMode(agent.session.events)).toBe(true) - expect((await ctx.commands.execute(agent, '/plan off', signal))?.result) + expect((await ctx.commands.execute(agent, '/plan off', [], signal))?.result) .toEqual({ kind: 'success', text: 'Plan mode off.' }) expect(foldPlanMode(agent.session.events)).toBe(false) }) + it('steers image attachments with or without text and refuses them on /plan off', async () => { + const ctx = await setup() + await ctx.plugin(CommandRuntime) + await new Promise(resolve => setImmediate(resolve)) + let saved = 0 + const saveImage = (input: { mediaType: string }) => { + saved += 1 + return Promise.resolve({ + attachmentId: `att-${saved}`, mediaType: input.mediaType, bytes: 3, width: 1, height: 1, + }) + } + ctx.provide('attachments', { + imageLimits: { + maxImageBytes: 1024, maxImagesPerMessage: 4, maxMessageImageBytes: 1024, + maxImagePixels: 1_000_000, mediaTypes: ['image/png'], + }, + validateImage: () => Promise.resolve(), + saveImage, + async saveImages(inputs: readonly { mediaType: string }[]) { + const refs = [] + for (const input of inputs) refs.push(await saveImage(input)) + return refs + }, + }) + const signal = new AbortController().signal + const images = [{ mediaType: 'image/png' as const, data: 'AAAA' }] + + const agent = await agentWithSession(ctx, 'imaged-plan-command') + openTurn(agent.session) + const steer = vi.fn() + ;(agent as unknown as { steer: typeof steer }).steer = steer + const withMessage = await ctx.commands.execute(agent, '/plan sketch the layout', images, signal) + expect(withMessage?.result.kind).toBe('success') + expect(steer).toHaveBeenCalledExactlyOnceWith({ + id: expect.any(String) as unknown, + role: 'user', + content: [ + { type: 'image', attachment: expect.objectContaining({ attachmentId: 'att-1' }) as unknown }, + { type: 'text', text: 'sketch the layout' }, + ], + source: { kind: 'user' }, + }) + + const bareAgent = await agentWithSession(ctx, 'imaged-bare-plan-command') + openTurn(bareAgent.session) + const bareSteer = vi.fn() + ;(bareAgent as unknown as { steer: typeof bareSteer }).steer = bareSteer + expect((await ctx.commands.execute(bareAgent, '/plan', images, signal))?.result) + .toEqual({ kind: 'success', text: 'Entering plan mode (applies from the next step). Use /plan off to leave.' }) + expect(bareSteer).toHaveBeenCalledExactlyOnceWith({ + id: expect.any(String) as unknown, + role: 'user', + content: [{ type: 'image', attachment: expect.objectContaining({ attachmentId: 'att-2' }) as unknown }], + source: { kind: 'user' }, + }) + expect(ctx.planMode.get(bareAgent)).toEqual({ active: false, pending: true }) + + const activeAgent = await agentWithSession(ctx, 'imaged-off-plan-command', { active: true }) + const offSteer = vi.fn() + ;(activeAgent as unknown as { steer: typeof offSteer }).steer = offSteer + expect((await ctx.commands.execute(activeAgent, '/plan off', images, signal))?.result) + .toEqual({ kind: 'error', text: 'Image attachments cannot accompany /plan off.' }) + expect(offSteer).not.toHaveBeenCalled() + expect(ctx.planMode.get(activeAgent)).toEqual({ active: true }) + }) + it('removes the contributed command when the plan-mode plugin is disposed', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) diff --git a/packages/plan/plan-mode/tests/projection.spec.ts b/packages/plan/plan-mode/tests/projection.spec.ts index 25f9a5197c..e0ad20148c 100644 --- a/packages/plan/plan-mode/tests/projection.spec.ts +++ b/packages/plan/plan-mode/tests/projection.spec.ts @@ -1,9 +1,10 @@ /** * The `plan` projection unit (session-projection RFC's complete example): a - * double-event fold over the session log. `command/run` records named `plan` - * 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. + * event fold over the session log. `command/run` records named `plan` with + * recorded input set the candidate target (`off` → false, anything else → + * true); `command/done` keeps successful candidates and drops failures; + * `plan/mode` commits and clears a selection. `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). @@ -47,13 +48,20 @@ async function harness(withPlanMode: boolean): Promise { } /** Append one logged /plan selection record (the executor's command/run shape). */ -function runPlanCommand(session: Session, args: string, index: number): void { +function runPlanCommand(session: Session, args: string, index: number): CommandId { + const commandId = CommandId(`plan-proj-${String(index)}`) session.append('command/run', { - commandId: CommandId(`plan-proj-${String(index)}`), + commandId, name: 'plan', args, source: { kind: 'user' }, }) + return commandId +} + +/** Append the paired settlement for one projected plan command. */ +function settlePlanCommand(session: Session, commandId: CommandId, kind: 'success' | 'error'): void { + session.append('command/done', { commandId, kind }) } /** Commit one plan/mode flip inside an open turn (the invariant's turn-enclosure rule). */ @@ -71,15 +79,23 @@ describe('plan projection unit', () => { it('a logged /plan selection reads pending until plan/mode records it', async () => { const bench = await harness(true) - runPlanCommand(bench.session, '', 0) + const commandId = runPlanCommand(bench.session, '', 0) expect(bench.values().plan).toEqual({ active: false, pending: true }) - // A repeated identical selection returns the same state reference (no frame). - runPlanCommand(bench.session, '', 1) + settlePlanCommand(bench.session, commandId, 'success') expect(bench.values().plan).toEqual({ active: false, pending: true }) commitPlanMode(bench.session, true, 0) expect(bench.values().plan).toEqual({ active: true, pending: false }) }) + it('drops a plan selection when its command settles with an error', async () => { + const bench = await harness(true) + commitPlanMode(bench.session, true, 0) + const commandId = runPlanCommand(bench.session, 'off', 0) + expect(bench.values().plan).toEqual({ active: true, pending: true }) + settlePlanCommand(bench.session, commandId, 'error') + expect(bench.values().plan).toEqual({ active: true, pending: false }) + }) + it('folds `off` args and non-plan commands correctly, and a matching selection is not pending', async () => { const bench = await harness(true) commitPlanMode(bench.session, true, 0) @@ -128,7 +144,7 @@ describe('plan projection unit', () => { // memory involved, the fold alone answers {active:false, pending:true}. const cold = await harness(true) for (const event of bench.session.events) { - if (event.type === 'command/run' || event.type === 'plan/mode') { + if (event.type === 'command/run' || event.type === 'command/done' || event.type === 'plan/mode') { cold.session.append(event.type, event.data) } } diff --git a/packages/session-query/session-log-export/tests/loader-composition.client.spec.ts b/packages/session-query/session-log-export/tests/loader-composition.client.spec.ts index facd7e13fd..2a993f52ef 100644 --- a/packages/session-query/session-log-export/tests/loader-composition.client.spec.ts +++ b/packages/session-query/session-log-export/tests/loader-composition.client.spec.ts @@ -60,7 +60,7 @@ describe('session-log-download real Loader composition', () => { expect(context.commands.list(agent)).toContainEqual({ name: 'export', description: 'Download this Session log as a ZIP archive', }) - const execution = await context.commands.execute(agent, '/export', new AbortController().signal) + const execution = await context.commands.execute(agent, '/export', [], new AbortController().signal) expect(execution?.result).toEqual({ kind: 'success', text: 'Session log download requested.' }) expect(session.events.map(event => event.type)).toEqual(['command/run', 'command/done']) expect(session.deriveMessages()).toEqual([]) diff --git a/packages/subprocess/subprocess-local/tests/fixtures/managed-tree.ts b/packages/subprocess/subprocess-local/tests/fixtures/managed-tree.ts index 31d26b9e39..a949e5fbfa 100644 --- a/packages/subprocess/subprocess-local/tests/fixtures/managed-tree.ts +++ b/packages/subprocess/subprocess-local/tests/fixtures/managed-tree.ts @@ -1,5 +1,5 @@ import { spawn } from 'node:child_process' -import { writeFile } from 'node:fs/promises' +import { rename, writeFile } from 'node:fs/promises' const [statePath] = process.argv.slice(2) if (statePath === undefined) throw new Error('usage: managed-tree.ts ') @@ -12,5 +12,7 @@ const descendant = spawn(process.execPath, [ ], { stdio: 'ignore' }) if (descendant.pid === undefined) throw new Error('managed descendant did not publish a pid') -await writeFile(statePath, JSON.stringify({ root: process.pid, descendant: descendant.pid })) +const pendingStatePath = `${statePath}.pending-${process.pid}` +await writeFile(pendingStatePath, JSON.stringify({ root: process.pid, descendant: descendant.pid })) +await rename(pendingStatePath, statePath) setInterval(() => {}, 60_000) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d562782045..8905b8d8b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5064,12 +5064,18 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 60acbf17dd..fac02f9db3 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -281,6 +281,7 @@ export const LINK_MAP: Readonly> = { ApprovalPolicy: 'approval.md', ApprovalRequest: 'approval.md', ApprovalService: 'approval.md', + EncodedImageAttachment: 'attachment.md', ImageAttachmentRef: 'attachment.md', SaveImageAttachment: 'attachment.md', StoredImageAttachment: 'attachment.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index f603244a6f..3c7038937c 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -905,6 +905,11 @@ "symbol": "ImageAttachmentLimits", "source": "packages/attachment/attachment/src/types.ts" }, + { + "doc": "docs/subsystems/attachment.md", + "symbol": "EncodedImageAttachment", + "source": "packages/attachment/attachment/src/types.ts" + }, { "doc": "docs/subsystems/attachment.md", "symbol": "SaveImageAttachment", diff --git a/tsconfig.base.json b/tsconfig.base.json index 189c38bb4c..4f97108033 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -53,6 +53,7 @@ "@deepseek-ai/dsh-session/invariant": ["./packages/core/session/src/invariant.ts"], "@deepseek-ai/dsh-typert-registry/types": ["./packages/typert/registry/src/types.ts"], "@deepseek-ai/dsh-typert-generator": ["./packages/typert/generator/src/index.ts"], + "@deepseek-ai/dsh-attachment/types": ["./packages/attachment/attachment/src/types.ts"], "@deepseek-ai/dsh-session/types": ["./packages/core/session/src/types.ts"], "@deepseek-ai/dsh-session/surface": ["./packages/core/session/src/surface.ts"], "@deepseek-ai/dsh-session-projection/types": ["./packages/session/session-projection/src/types.ts"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 37c40b7609..0e8423aa03 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -19,6 +19,7 @@ "apps/web/tests/live-interactions.e2e.ts", "apps/web/tests/question-composer.e2e.ts", "apps/web/tests/approval-composer.e2e.ts", + "apps/web/tests/plan-control-row.e2e.ts", "apps/web/tests/plan-review.e2e.ts", "apps/web/tests/steering.e2e.ts", "apps/web/tests/navigation-panes.e2e.ts",