Merge remote-tracking branch 'origin/master' into dshw/pr-deepseek-harness-deepseek-harness-2300

This commit is contained in:
_Kerman
2026-08-19 14:04:04 +08:00
58 changed files with 3561 additions and 554 deletions
@@ -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-13-feedback-note-editor-popover.md
2026-08-13-feedback-note-editor-popover.md: 33b7cd84b97ceac7fef1d96f1dee279fbb215800
2026-08-13-feedback-note-editor-popover.zh.md: b130ab1e9c66372cf2a52bc5e12f65027c64cf51
@@ -0,0 +1,43 @@
# Agent Note: The feedback note editor floats above the transcript in a popover
Status: implemented
English | [中文](2026-08-13-feedback-note-editor-popover.zh.md)
## Problem
The Web surface for message feedback ([#2262](https://github.com/deepseek-harness/deepseek-harness/pull/2262)) contributes its controls to `conversation.chat.assistant-actions`, which renders inside the finalized assistant message's shared IconActions row. That row was one fixed-height `flex` line with `flex-wrap` at its initial `nowrap` and `height: 28px`, sized for 28px icons and a clock. The note editor mounted into it as an inline group holding a `width: 260px` textarea plus Save and Cancel.
A 260px input and two buttons do not fit that line at any window size. Measured against the shipped bundle, the row's scrollable overflow with the editor open was 168px at a 1680px viewport and 444px at 600px — the defect was never a narrow-window edge case, it was present at full-screen desktop. Flex overflow spills past the end of the line, so the items after the editor in flex order were the ones pushed out of the conversation column: the branch action left the column at 600px, and the clock and its run/TTFT/throughput readings left it at 900px. Those controls stay hit-testable while invisible, so no behavioral assertion noticed; the shipped e2e covered rate, note, reload, and retract, and the 24 UI snapshots are width-independent DOM.
The same stylesheet also named four `--dsw-alias-*` tokens that the theme does not define: `border-secondary`, `bg-primary`, `interactive-bg-primary`, and `label-inverse`. An undefined custom property makes its whole declaration invalid at computed-value time, so the textarea shipped with no border and no surface, and Save with neither fill nor a readable label — the editor read as loose text floating in the transcript rather than as an input.
## Decision
The note editor does not enter the row's flex layout at all. It is a popover: a fixed-position panel, portaled to `document.body`, whose coordinates come from the note trigger's rect. The row keeps its single line of icons and the note trigger, so nothing has to shrink, wrap, or reflow around the editor, and no `order` or wrapping is needed anywhere. Portaling out of the conversation column also escapes its `overflow` clip, so the panel cannot be cropped at the scroll edge and it moves with the message it annotates when the transcript scrolls. This reuses the same portal mechanism `ui-primitives/Menu` uses for anchored menus (`ui-subagent`'s catalog popover is built on it): the panel is `position: fixed`, placed from the anchor rect on open, clamped inside the viewport, and re-placed on scroll (capture phase) and resize. That anchoring is shared rather than copied: `ui-primitives/useAnchoredPosition` owns measure-offset-clamp-and-track, and the duplication gate is what forced the extraction — an inline copy of the clamp and its listener pair reported a 10-line clone against `Menu`. `Menu` keeps its own effect because its placement also resolves `side`/`align` variants and an optional caller-supplied anchor rect, which this surface does not need; the hook covers the plain below-the-anchor case both would otherwise spell out.
**The action strip.** The like/dislike buttons and the note trigger stay in the row, unchanged. The trigger is a plain button (`aria-haspopup="dialog"`, `aria-expanded` while open) that shows "Add a note" before a note exists and the note text afterward.
**The popover.** While open, the panel contains the textarea plus Save and Cancel, and any note-save failure, as `role="dialog"` with a title distinct from the textarea's own label so both are addressable by name. It opens beneath the trigger (4px gap), clamps to 12px from the viewport edges, auto-focuses the textarea, and closes on Escape or an outside pointer-down. Closing returns focus to the trigger only when the panel was really open, never on the initial mount (a freshly rendered rated message must not pull focus into its action row). A rating action during an open editor closes the panel. The four undefined tokens are replaced with the ones the theme actually defines, matching the primitives' precedent: `border-l2` and `bg-layer-1` for the input, `button-primary-fill` with `label-primary-foreground` plus a `button-primary-hover` state for Save; the panel surface reuses the Menu card recipe (`--dsw-specific-menu`, `--dsw-shadow-lv3`, inverted hairline `--dsw-alias-border-inverted`, `border-radius: 12px`).
**Failure surfaces split by where the human is looking.** A rating or list-load failure shows beside the buttons in the row, legible whether or not the popover is open. A note-save failure shows inside the popover, next to Save/Cancel, and the panel stays open so the draft survives to be corrected.
## Alternatives considered
**Inline expansion on the row, the editor claiming its own line via a full-width flex basis with the row allowed to wrap** — the approach first shipped on this branch and rejected here. It fixes the geometry (the row reports zero overflow from 1680px down to 600px) but at a visible cost: the branch action and the end clock wrap below the editor while it is open, the row occupies three lines, and the interaction competes for the same horizontal strip the row already fills. That cost is what [#2561](https://github.com/deepseek-harness/deepseek-harness/issues/2561) reported from real use — the row reads as misaligned once the editor expands — and it asked for the popover the chat surface already uses. A popover removes the editor from the row entirely, so the strip and the keyboard tab order are untouched whether the editor is open or not.
**An absolutely-positioned popover not portaled out of the column** — rejected: the conversation column is an `overflow-y: auto` scroller, so a panel laid out inside it is clipped at the scroll edge and does not track the message as the column scrolls. Portaling to `document.body` with fixed placement from the trigger rect is what makes the floating panel viable, exactly as `Menu`'s portal mode and the subagent catalog popover already do.
**A new `belowActions` seam on `MessageIconActions`, rendering the editor as a sibling under the row** — rejected: the slot contract documents `assistant-actions` as rendering *inside* the message's IconActions row, and one entry cannot supply two render sites without widening the host contract for a presentation detail that a portaled popover already expresses without touching the host.
## Consequences
With the editor open the actions row stays a single 28px line with zero overflow and nothing outside the column at every viewport from 1680px down to 600px — because the editor is not in the row to begin with. The panel floats above the transcript inside the viewport and stays anchored to its trigger, escaping the column's overflow clip. The editor is legible as an input in both themes.
`apps/web/tests/message-feedback-layout.e2e.ts` sweeps six viewports with the editor open and pins, per stop, that the row reports one line and zero overflow, that the panel is outside the conversation column (proof it escapes the clip), that it lies within the viewport (proof the clamp holds), and that it sits by its trigger. A committed golden records the relations; reverting to inline (or dropping the portal) fails the geometry assertions. `packages/client/ui-message-feedback/tests/styles.client.spec.ts` checks the tokens against the theme's committed source, that the panel is `position: fixed`, and that it carries no flex sizing (so it cannot rejoin the row), plus the brace balance, following the `ui-settings-models` styles-spec precedent. The unit spec covers rate, note, reload, retract, plus the popover's portal-to-body, Escape/outside-click dismissal, and keep-open-on-inside-click.
The `ui-message-feedback` package adds `@types/react-dom` so the `createPortal` usage typechecks, mirroring `ui-primitives`.
Known limitations are accepted rather than fixed here. A rating click while the panel is open closes it, and the close path returns focus to the note trigger rather than leaving it on the rating button the human just pressed; the same happens when an outside click lands on another focusable control, which the browser focuses before the close returns focus to the trigger. A pointer user does not notice either; a keyboard user feels the focus move. The clamp assumes the panel fits: a panel taller than the viewport makes the upper bound `innerHeight - height - margin` smaller than `margin`, so `top` goes negative and the panel's head is cut off rather than its foot. The panel's three-row textarea carries `resize: vertical`, so a human can drag past that size; `.notePanel` therefore bounds its height at `calc(100vh - 24px)` and scrolls its own content, the counterpart of the existing `max-width` and the same 12px margin the clamp uses. If the rating disappears while the editor is open, the panel unmounts on the `rating !== undefined` guard but `noteOpen` stays true, so the document-level Escape and pointer-down listeners remain attached; should the item reappear through a later resync, the panel returns with the previous draft and without refocusing the textarea. The window is one click or Escape wide, and the save failure it could hide already falls back to the row, so it is left as it is. A failure that lands after the panel was closed and reopened is not written into the new session's panel: its draft was reseeded from the stored note, so an old attempt's error would mislabel it, and the uncommitted content is already gone — the failure is dropped rather than shown. And while the placement replays on scroll, window resize, and the panel's own size changes, jsdom has no layout, so the real geometry is proven by the browser scenario while the unit spec covers the wiring through a `ResizeObserver` stub.
A residual narrow-viewport clock overflow remains below 520px from the clock string alone, unrelated to the feedback surface. The repo has no gate for undefined design tokens, and a scan during this work found more in `ui-agent-preset`, `ui-conversation`, `ui-jobs`, `ui-settings-plugins`, and `ui-tool`; they are untouched here and want their own change.
@@ -0,0 +1,43 @@
# Agent Note:反馈备注编辑器以浮层悬浮在对话记录上方
Status: implemented
[English](2026-08-13-feedback-note-editor-popover.md) | 中文
## Problem
消息反馈的 Web 界面([#2262](https://github.com/deepseek-harness/deepseek-harness/pull/2262))把控件贡献给 `conversation.chat.assistant-actions`,该槽位渲染在已定稿助手消息共享的 IconActions 行内。那一行是单条固定高度的 `flex` 线,`flex-wrap` 保持初始值 `nowrap``height: 28px`,按 28px 图标加一个时钟来定尺寸。备注编辑器作为一个内联组挂进去,内含 `width: 260px` 的 textarea 加 Save 与 Cancel。
一个 260px 输入框加两个按钮在任何窗口尺寸下都装不进那条线。对着已构建产物实测,编辑器打开时该行的可滚动溢出在 1680px 视口下是 168px,在 600px 下是 444px——这个缺陷从来不是窄窗口的边缘情况,在全屏桌面下就已存在。flex 溢出会溢出到线的末端之外,因此按 flex 顺序排在编辑器之后的项被挤出会话列:branch 操作在 600px 时离开列,时钟及其运行时长/TTFT/吞吐读数在 900px 时离开列。这些控件在不可见的同时仍可命中测试,所以没有任何行为断言发现它;已交付的 e2e 覆盖评分、备注、reload 与撤回,而 24 个 UI 快照是与宽度无关的 DOM。
同一张样式表还引用了四个主题并未定义的 `--dsw-alias-*` token`border-secondary``bg-primary``interactive-bg-primary``label-inverse`。未定义的自定义属性会让其所在的整条声明在 computed-value 阶段失效,因此 textarea 交付时既无边框也无底色,Save 既无填充也无可读标签——编辑器读起来像是浮在对话记录里的散落文本,而不是一个输入框。
## Decision
备注编辑器完全不进入行的 flex 布局。它是一个浮层:一张固定定位的面板,portal 到 `document.body`,其坐标来自备注触发按钮的矩形。行保持其单行图标与备注触发按钮,因此没有任何东西需要围绕编辑器收缩、换行或回流,任何地方都不需要 `order` 或换行。portal 出会话列也逃出了列的 `overflow` 裁剪,因此面板不会被滚动边缘裁掉,并且当对话记录滚动时会随它所批注的消息一起移动。这里复用 `ui-primitives/Menu` 为锚定菜单所用的同一套 portal 机制(`ui-subagent` 的 catalog popover 就构建在它之上):面板 `position: fixed`,打开时从 anchor rect 定位,钳制在视口内,并在滚动(捕获阶段)与缩放时重新定位。这套锚定逻辑是共享而非复制的:`ui-primitives/useAnchoredPosition` 持有「测量—偏移—钳制—跟随」这一件事,而促成这次抽取的正是重复代码门禁——内联的钳制与那对监听器被报为与 `Menu` 的 10 行克隆。`Menu` 保留自己的 effect,因为它的定位还要解析 `side`/`align` 变体与可选的调用方 anchor rect,而本界面不需要这些;该 hook 覆盖的是两边本来都要各写一遍的「锚点正下方」这一简单情形。
**操作条。** 点赞/点踩按钮与备注触发按钮保持原样留在行内。触发按钮是普通 `button``aria-haspopup="dialog"`,打开时 `aria-expanded`),在没有备注时显示「补充说明」,已有备注时显示备注文本。
**浮层。** 打开时,面板内含 textarea、Save 与 Cancel,以及任何备注保存失败提示,作为 `role="dialog"`,其标题与 textarea 自身的标签不同,以便两者都能按名称寻址。它在触发按钮下方打开(4px 间距),钳制到距视口边缘 12px,自动聚焦 textarea,并在 Escape 或外部 pointer-down 时关闭。关闭时仅当面板确实曾经打开才把焦点还给触发按钮,绝不会在初始挂载时(新渲染出的一条已评分消息不得把焦点拉进其操作条)。编辑器打开时进行评分操作会关闭面板。四个未定义 token 换成主题确实定义的那些,与 primitives 的既有做法一致:输入框用 `border-l2``bg-layer-1`Save 用 `button-primary-fill``label-primary-foreground` 并加 `button-primary-hover` 状态;面板表面复用 Menu 卡片的配方(`--dsw-specific-menu``--dsw-shadow-lv3`、反色发丝线 `--dsw-alias-border-inverted``border-radius: 12px`)。
**失败提示按人的视线所落之处拆分。** 评分或列表加载失败显示在按钮旁的图标行里,无论浮层是否打开都清晰可读。备注保存失败显示在浮层内、Save/Cancel 旁,且面板保持打开,以便草稿留存待修正。
## Alternatives considered
**行内展开:编辑器通过整行 flex basis 独占一行,并让行允许换行** — 这是本分支最初交付、在此否决的做法。它修好了几何(行在 1680px 到 600px 报告零溢出),但有可见代价:branch 与末尾时钟在编辑器打开时换行到编辑器下方,行占三行,交互与行本就占满的横向条带争空间。这一代价正是 [#2561](https://github.com/deepseek-harness/deepseek-harness/issues/2561) 在真实使用中反馈的问题——编辑器展开后这一行读起来是错位的——并提出改用 chat 界面已有的弹窗。浮层把编辑器完全移出行,因此无论编辑器是否打开,操作条与键盘 Tab 顺序都不受影响。
**不 portal 出列的绝对定位浮层** — 否决:会话列是 `overflow-y: auto` 的滚动容器,因此在列内布局的面板会被滚动边缘裁掉,且不随列滚动而跟住消息。portal 到 `document.body` 并从触发按钮矩形做固定定位,才让浮动面板可行,正如 `Menu` 的 portal 模式与 subagent catalog popover 已然做到的那样。
**在 `MessageIconActions` 上新增 `belowActions` 接缝,把编辑器作为该行的兄弟节点渲染在下方** — 否决:slot 契约明确记载 `assistant-actions` 渲染在消息 IconActions 行**内部**,且单个条目无法在不为一个展示细节拓宽 Host 契约的前提下提供两个渲染点,而 portal 出的浮层无需触碰 Host 就表达了该细节。
## Consequences
编辑器打开时,操作行保持单条 28px 线,在 1680px 到 600px 的每一档视口都零溢出、零项落在列外——因为编辑器本就不在行里。面板悬浮于对话记录之上、位于视口内,并保持锚定其触发按钮,逃出列溢出裁剪。编辑器在两种主题下都能被辨认为输入框。
`apps/web/tests/message-feedback-layout.e2e.ts` 在编辑器打开时扫描六个视口,并在每一档钉住:行报告单行零溢出、面板位于会话列之外(证明它逃出裁剪)、面板落在视口内(证明钳制有效)、面板紧贴其触发按钮。已提交的 golden 记录这些关系;回退到行内(或去掉 portal)会让几何断言失败。`packages/client/ui-message-feedback/tests/styles.client.spec.ts` 校验 token 与主题已提交的源一致、面板为 `position: fixed`、且不带任何 flex sizing(因此不会重新加入行),并校验大括号平衡,沿用 `ui-settings-models` styles spec 的先例。单元 spec 覆盖评分、备注、reload、撤回,外加浮层的 portal 到 body、Escape/外部点击关闭、以及浮层内部点击保持打开。
`ui-message-feedback` 包新增 `@types/react-dom`,使 `createPortal` 用法能通过类型检查,与 `ui-primitives` 一致。
有若干已知限制在此接受而非修复。面板打开时点击评分会关闭它,而关闭路径把焦点归还给备注触发按钮,而不是留在用户刚按下的评分按钮上;外部点击落在另一个可聚焦控件上时同理——浏览器先把焦点给该控件,随后关闭路径又把它拉回触发按钮。指针用户对两者都无感,键盘用户会察觉焦点移动。钳制假定面板放得下:面板高于视口时,上界 `innerHeight - height - margin` 会小于 `margin`,于是 `top` 变为负值、被裁掉的是面板顶部而非底部。面板里的三行 textarea 带 `resize: vertical`,用户可以拖过这个尺寸,因此 `.notePanel` 把自身高度限制在 `calc(100vh - 24px)` 并自行滚动内容——这是既有 `max-width` 的对应项,用的是与钳制相同的 12px 边距。编辑器打开时若评分消失,面板会因 `rating !== undefined` 守卫卸载,但 `noteOpen` 仍为 true,因此 document 级的 Escape 与 pointer-down 监听继续挂着;若该 item 之后经 resync 重新出现,浮层会带着上一次的草稿回来且不重新聚焦 textarea。该窗口只有一次点击或一次 Escape 那么宽,而它可能遮住的保存失败已经有行内回退,因此保持现状。若失败在面板关闭并重开后才到达,不会写入新会话的面板:其草稿已按已存备注重新播种,旧尝试的错误会误标新草稿,而未提交的内容本就不存在——该失败被丢弃而不展示。以及,定位虽然会在滚动、窗口缩放与面板自身尺寸变化时重放,但 jsdom 没有布局,因此真实几何由浏览器场景证明,单测则通过 `ResizeObserver` stub 覆盖其接线。
520px 以下仍残留仅来自时钟字符串的窄视口溢出,与本界面无关。仓库没有针对未定义设计 token 的门禁;本次工作中的一次扫描在 `ui-agent-preset``ui-conversation``ui-jobs``ui-settings-plugins``ui-tool` 中又发现更多,本次未触碰,需要单独的改动处理。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md
2026-08-04-claude-code-and-codex-subagent-backends.md: a8f500c7fb934b8634456e1618498909f682f0e2
2026-08-04-claude-code-and-codex-subagent-backends.zh.md: ca35c38617b1ca38757959735b11618559f6f804
2026-08-04-claude-code-and-codex-subagent-backends.md: 9b47fcf49d47d2c3561245fa1e16ff8c5da0a35c
2026-08-04-claude-code-and-codex-subagent-backends.zh.md: fcb1aac71be2da9d907ad67867c763e3051baec5
@@ -12,7 +12,7 @@ The product integrations must not become second owners for task text, cwd, cance
## Decision
The harness publishes two sibling one-shot provider packages whose default registry names are `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [named-instance decision](2026-08-18-product-subagent-named-instances.md) owns Profile-selected provider identity and static tool binding, the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their independent optional Bundles and host-plane placement, the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice, and the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile-selected mode and diagnostic production. Both packages accept multiple named instances. Loading either provider starts no product process, and each tool accepts only a standalone text task; product and instance selection remain deployment configuration.
The harness publishes two sibling one-shot provider packages whose default registry names are `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [named-instance decision](2026-08-18-product-subagent-named-instances.md) owns Profile-selected provider identity and static tool binding, the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their independent optional Bundles and host-plane placement, the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice, the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile-selected mode and safe permission decisions, and the [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns version-pinned product categories, lifecycle stages, and process outcomes exposed through the same diagnostic. Both packages accept multiple named instances. Loading either provider starts no product process, and each tool accepts only a standalone text task; product and instance selection remain deployment configuration.
Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools use `backgroundMode: 'one-shot'` and `maxDepth: 'provider-managed'`: the consumer keeps foreground collection as the default and may place the same run in the generic Job runtime, while recursion policy stays with the out-of-process product. Every call creates a fresh product process and a non-resumable product conversation. `ctx.subagents` owns named-request resolution and paired lifecycle events; `dsh-tool-subagent` owns model-visible scheduling and foreground-versus-Job adaptation; `ctx.jobs` and `dsh-tool-jobs` own Job ids, state, output, controls, notices, and parent-owner cancellation; each product provider owns native result mapping, while `dsh-subprocess` owns credential scrubbing, process-tree termination, and whole-tree exit observation.
@@ -38,11 +38,11 @@ configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> pro
Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize``initialized`, maps the resolved mode into official `thread/start` fields, and creates an `ephemeral: true` thread. The fixed app-server argv contains no mode or task text. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session.
`turn/completed` is the authoritative remote terminal fact. The latest `agentMessage` with `phase: "final_answer"` wins, and that selected message must contain nonblank text. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback and must likewise be nonblank; commentary never replaces either answer. A failed turn with `error.codexErrorInfo: "contextWindowExceeded"` becomes `max-tokens`. A completed turn without an answer, every other failed or interrupted remote turn, malformed required fields in a recognized app-server frame, protocol closure, early process exit, or unknown server request becomes `error`; a permission-related error may additionally carry the shared safe diagnostic. This version has no native refusal terminal and therefore produces no `refusal`. Local cancellation wins its race and remains `aborted` without permission detail.
`turn/completed` is the authoritative remote terminal fact. The latest `agentMessage` with `phase: "final_answer"` wins, and that selected message must contain nonblank text. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback and must likewise be nonblank; commentary never replaces either answer. The [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns Codex error-info categories, HTTP status, lifecycle stages, process outcomes, and stop-reason preservation. Local cancellation remains `aborted` without a failure diagnostic.
For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. It records safe categories for those requests, declined command/file items, and `sandboxError`. Codex emits some early `never` rejections and sandbox violations only on structured stderr, so the Provider pipes and forwards stderr unchanged while matching two fixed signatures in a bounded per-run tail; raw stderr never enters the diagnostic. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply.
An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, detaches the stderr observer, and then rejects `start()`. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, waits for whole-tree exit, and detaches the observer. Result failure and teardown failure stay independently observable.
An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, detaches the stderr observer, and then rejects `start()` with its fixed operation stage. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, waits for whole-tree exit, and detaches the observer. Independent cleanup failure reports `teardown`; when startup and rollback both fail, the aggregate's top message retains both safe stage lines while the underlying causes remain internal.
Codex 0.147.0 speaks the Responses protocol, while DeepSeek's public OpenAI-compatible endpoint speaks Chat Completions. The credentialed Codex e2e therefore uses a loopback-only, test-private bridge for one no-tool nonce request: real Codex sends Responses to the bridge, the bridge forwards the received bearer credential and extracted task to the fixed official DeepSeek endpoint, and it wraps the real text in the minimal Responses SSE lifecycle. The bridge is neither a production proxy nor evidence that Codex connects to DeepSeek Chat Completions natively.
@@ -52,9 +52,9 @@ Codex 0.147.0 speaks the Responses protocol, while DeepSeek's public OpenAI-comp
The public configuration contains a non-empty `providerName`, an explicit `env` overlay, a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`, and a five-value native `permissionMode` that defaults to `dontAsk`. Each named instance retains those resolved values for its own runs. Each run creates its own `AbortController`, sets `persistSession: false`, disables `AskUserQuestion`, and passes the resolved mode to the SDK; only `bypassPermissions` receives the SDK's explicit dangerous confirmation. The provider deliberately omits `settingSources`, so the SDK reads the host's normal user, project, and local Claude settings relative to the parent Session cwd. It neither copies nor filters those settings and does not create or modify login state. Remaining permission prompts are denied, MCP elicitation is declined, and blocking dialogs fail closed instead of waiting for a user interface the provider does not own.
The provider publishes only after both the SDK `Query` and a live managed CLI handle exist. It consumes the complete SDK stream and completes only when a `result` message has `subtype: "success"`, `is_error: false`, and a nonblank `result`, and the iterator then ends normally. Every SDK error subtype, an error-marked success, a missing result, iterator failure, protocol failure, or process failure becomes `error`. When a permission denial or unattended callback contributes to that failure, the result may additionally carry the bounded, non-assistant diagnostic owned by the non-interactive permissions decision. SDK turn, budget, and structured-output limits are not token-window facts, and the SDK exposes no native refusal terminal, so this provider produces neither `max-tokens` nor `refusal`. Local cancellation wins and becomes `aborted` without permission detail.
The provider publishes only after both the SDK `Query` and a live managed CLI handle exist. It consumes the complete SDK stream and completes only when a `result` message has `subtype: "success"`, `is_error: false`, and a nonblank `result`, and the iterator then ends normally. The [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns every non-success category, stage, process outcome, and its ordering with a contributing permission decision. Local cancellation wins and becomes `aborted` without either diagnostic fact.
Startup rollback and published disposal close the SDK query, abort the per-run controller, invoke shared process-tree termination, and wait for whole-tree exit. `Query.close()` expresses graceful protocol intent but does not replace the subprocess owner's exit proof. Query-close failure, process failure, and teardown failure remain independently observable.
Startup rollback and published disposal close the SDK query, abort the per-run controller, invoke shared process-tree termination, and wait for whole-tree exit. `Query.close()` expresses graceful protocol intent but does not replace the subprocess owner's exit proof. An unpublished failure exposes only fixed `query-start` facts; a published process failure can expose its independent exit code and signal; an independent cleanup rejection exposes `teardown`. Original SDK, Host, and cleanup errors remain on internal cause chains and logs rather than entering the diagnostic.
The credentialed Claude Code e2e uses the official DeepSeek Claude Code contract directly: the runtime-only DeepSeek key becomes `ANTHROPIC_AUTH_TOKEN`, the fixed official base gains `/anthropic`, and the main and subagent model variables select the documented DeepSeek models. It starts the production provider and real SDK/CLI, requires one random nonce as the complete answer, persists no credential in settings, and waits for every managed handle to exit.
@@ -62,11 +62,11 @@ The credentialed Claude Code e2e uses the official DeepSeek Claude Code contract
Each product owns branch-complete package tests, a required keyless real-product spec, a Loader composition e2e, and a credentialed DeepSeek e2e. The keyless product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The Codex Loader fixture exposes two named Codex instances and tools; the Claude Code Loader fixture exposes the default Codex tool plus two named Claude Code instances and tools. Both fixtures include generic Job controls and start neither product process. The credentialed tier starts the same production provider and real product with a runtime-only key, requires a unique nonce from the fixed official DeepSeek service, and proves quiescence again; it self-skips only when a local operator supplied no key, while trusted CI preflights the secret.
The Codex evidence pins `@openai/codex@0.147.0`, `codex-cli 0.147.0`, and all six optional platform aliases. Its real-product spec observes the package-local wrapper argv, exact Bearer key, original task, byte-exact final answer, thread-level `never` overriding ambient `on-request`, automatic-review startup, unattended command rejection with safe diagnostic and no file side effect, explicit dangerous-bypass writing in suite-owned temporary storage, local cancellation, wrapper/native whole-tree exit, and missing-payload failure without host fallback.
The Codex evidence pins `@openai/codex@0.147.0`, `codex-cli 0.147.0`, and all six optional platform aliases. Its real-product spec observes the package-local wrapper argv, exact Bearer key, original task, byte-exact final answer, native permission modes, explicit dangerous-bypass writing in suite-owned temporary storage, and wrapper/native whole-tree exit. An isolated wrapper fixture proves missing-payload failure without host fallback, two named instances retain separate environments and modes, and production never resolves a host `codex` from `PATH`. The [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns schema, failure, process-outcome, and final presentation evidence.
The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit.
The Claude Code evidence pins Agent SDK 0.3.220, Claude Code 2.1.220, and the identities and versions of all eight SDK platform packages. Its real-product spec lets the SDK select the installed payload, asserts that the shared subprocess argv begins with that package's native CLI, and observes the exact `x-api-key`, original task, byte-exact final answer, an inherited interactive host setting overridden by the safe Provider mode, denied and bypassed writes in suite-owned temporary directories, safe permission diagnostics, process failure, local cancellation, and whole-tree exit. Unit coverage proves that production never resolves host `PATH`, omits the executable override, forwards the SDK-selected Windows `claude.exe` without a batch shim, and surfaces the SDK's missing-payload error without host fallback. This evidence proves the pinned official SDK/CLI integration rather than compatibility with independently installed Claude versions. Loader coverage resolves both products through their optional Bundle patches while starting neither product.
The Claude Code evidence pins Agent SDK 0.3.220, Claude Code 2.1.220, and all eight SDK platform packages. Its real-product spec lets the SDK select the installed payload, asserts that the shared subprocess argv begins with that package's native CLI, and observes the exact `x-api-key`, original task, byte-exact final answer, native permission modes, suite-owned denied and bypassed writes, and whole-tree exit. Package tests prove that production never resolves host `PATH`, omits the executable override, and forwards the SDK-selected Windows `claude.exe` without a batch shim. This evidence proves the pinned official SDK/CLI integration rather than compatibility with independently installed Claude versions; the [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns failure and process-outcome evidence. Loader coverage resolves both products through their optional Bundle patches while starting neither product.
The Claude Code credentialed e2e maps the key and fixed official endpoint only in the provider's in-memory environment, uses the documented `deepseek-v4-pro[1m]` and `deepseek-v4-flash` model variables, and traverses the production provider, official SDK, and real CLI. It compares the trimmed result with a random nonce and proves whole-tree exit without calling the Messages API directly from the test.
@@ -90,6 +90,6 @@ The project owner's distribution authorization is scoped to the official `@anthr
Users delegate through Profile-configured one-shot tools backed by the official product integrations. Explicit Profile installation and host-plane provider placement are owned by the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md); named instance identity and tool binding are owned by the [named-instance decision](2026-08-18-product-subagent-named-instances.md); per-Preset tool exposure and foreground-default optional Job scheduling are owned by the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md). This note's provider lifecycle keeps native settings and behavior while shared services retain the sole ownership of job settlement and process-tree quiescence.
Every delegation pays for a fresh product process and independent model context. Successful product payload remains final assistant text; a failed product run may separately expose the shared safe diagnostic. Background scheduling additionally exposes generic Job ids, status, completion notices, and collection or cancellation results. Both products use Bundle-pinned platform CLIs plus native account and workspace settings and the selected Provider permission mode. Credentialed e2e runs also spend external API quota and depend on the official DeepSeek endpoint; deterministic protocol, failure, cancellation, and approval coverage remains in the keyless tier. The providers do not resume sessions, stream progress, accept new human interaction, roll back tool or file side effects, or impose a wall-clock timeout.
Every delegation pays for a fresh product process and independent model context. Successful product payload remains final assistant text; a failed product run may separately expose the shared safe diagnostic containing provider-owned permission facts or version-pinned structured failure facts. Background scheduling additionally exposes generic Job ids, status, completion notices, and collection or cancellation results. Both products use Bundle-pinned platform CLIs plus native account and workspace settings and the selected Provider permission mode. Credentialed e2e runs also spend external API quota and depend on the official DeepSeek endpoint; deterministic protocol, failure, cancellation, and approval coverage remains in the keyless tier. The providers do not resume sessions, stream progress, accept new human interaction, roll back tool or file side effects, or impose a wall-clock timeout.
Compatibility is pinned by package-level unit coverage, keyless real-product loopback tests, credentialed DeepSeek nonce tests, public Loader composition, built-package and NodeNext consumer checks, generated documentation and notices, and the repository CI matrix. A supported product or DeepSeek endpoint/model baseline change must refresh those facts; production performs no separate runtime version probe.
@@ -12,7 +12,7 @@ Status: implemented
## 决策
harness 交付两个同级的一次性提供方包,其默认注册名称分别为 `codex``claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[命名实例决策](2026-08-18-product-subagent-named-instances.md)负责 Profile 选择的提供方身份与静态工具绑定,[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责各自独立的可选 Bundle 与 host plane(宿主平面)放置,[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责模型可见的调度选择,[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)负责各产品提供方的 Profile 模式选择与诊断生产。两个包都接受多个命名实例。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品与实例选择仍属于部署配置。
harness 交付两个同级的一次性提供方包,其默认注册名称分别为 `codex``claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[命名实例决策](2026-08-18-product-subagent-named-instances.md)负责 Profile 选择的提供方身份与静态工具绑定,[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责各自独立的可选 Bundle 与 host plane(宿主平面)放置,[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责模型可见的调度选择,[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)负责各产品提供方的 Profile 模式选择与安全权限决定,[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.md)则负责通过同一诊断公开锁定产品版本的类别、生命周期阶段与进程结果。两个包都接受多个命名实例。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品与实例选择仍属于部署配置。
这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动能力,并传递父会话 cwd,但不会复制父级对话。文档所示的工具使用 `backgroundMode: 'one-shot'``maxDepth: 'provider-managed'`:消费方默认在前台收集结果,也可把同一次运行放入通用 Job 运行时,而递归策略仍由进程外产品负责。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。`ctx.subagents` 负责具名请求解析与成对生命周期事件;`dsh-tool-subagent` 负责模型可见的调度以及前台与 Job 适配;`ctx.jobs``dsh-tool-jobs` 负责 Job id、状态、输出、控制、通知与父级 owner 取消;各产品提供方负责原生结果映射,`dsh-subprocess` 则负责凭证清洗、进程树终止以及整棵进程树的退出观测。
@@ -38,11 +38,11 @@ configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> pro
发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize``initialized` 握手,把已解析模式映射为官方 `thread/start` 字段,并创建一个 `ephemeral: true` 线程。固定 app-server argv 不包含模式或任务文本。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。
`turn/completed` 是权威的远端终止事实。以最后一条带有 `phase: "final_answer"``agentMessage` 为准,且选中的消息必须包含非空白文本。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退,该消息也必须包含非空白文本;过程说明绝不会取代上述任一答案。带有 `error.codexErrorInfo: "contextWindowExceeded"` 的失败轮次会成为 `max-tokens`。轮次完成却没有答案、其他任何远端失败或中断轮次、已识别的 app-server 帧中必需字段格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`;权限相关错误可以额外携带共享安全诊断。本版本没有原生的拒绝终止状态,因此不会产生 `refusal`。本地取消在竞态中胜出并保持为 `aborted`且不附带权限说明
`turn/completed` 是权威的远端终止事实。以最后一条带有 `phase: "final_answer"``agentMessage` 为准,且选中的消息必须包含非空白文本。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退,该消息也必须包含非空白文本;过程说明绝不会取代上述任一答案。[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.md)负责 Codex error-info 类别、HTTP status、生命周期阶段、进程结果与终止原因保持。本地取消仍是 `aborted` 且不附带失败诊断
对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。它会记录这些请求、被拒绝的命令/文件 item 与 `sandboxError` 的安全类别。Codex 的部分早期 `never` 拒绝和 sandbox violation 只写入结构化 stderr,因此提供方会 pipe 并原样转发 stderr,同时在每次运行的有界尾部中匹配两个固定签名;原始 stderr 绝不会进入诊断。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。
若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树、等待其退出、移除 stderr observer,然后拒绝 `start()`。对已发布的运行执行资源释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的逐级终止机制,等待整棵进程树退出,并移除 observer。结果失败与清理失败仍可彼此独立地观察
若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树、等待其退出、移除 stderr observer,然后用固定操作阶段拒绝 `start()`。对已发布的运行执行资源释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的逐级终止机制,等待整棵进程树退出,并移除 observer。独立清理失败会报告 `teardown`;启动与回滚同时失败时,聚合的顶层消息会保留两条安全阶段说明,而底层 cause 仍只在内部可见
Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端点使用 Chat Completions。因此,带密钥 Codex e2e 会采用一个仅限回环、仅供测试内部使用的桥接层来处理一次不使用工具的随机数请求:真实 Codex 将 Responses 发送到桥接层,桥接层把收到的 Bearer 凭据与提取出的任务转发到固定的 DeepSeek 官方端点,再将真实文本包装进最小化的 Responses SSEServer-Sent Events)生命周期。该桥接层既不是生产代理,也不能作为 Codex 原生连接 DeepSeek Chat Completions 的证据。
@@ -52,9 +52,9 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端
公开配置包含非空的 `providerName`、显式的 `env` 覆盖项、须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS``disposeGraceMs`,以及默认使用 `dontAsk` 的五值原生 `permissionMode`。每个命名实例会为自己的运行保留这些已解析值。每次运行都会创建自己的 `AbortController`,设置 `persistSession: false`、禁用 `AskUserQuestion`,并把已解析模式传给 SDK;只有 `bypassPermissions` 会取得 SDK 的显式危险确认。提供方故意省略 `settingSources`,因此 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置。它既不复制也不过滤这些设置,也不会创建或修改登录状态。其余权限提示会被拒绝,MCP elicitation 会被拒绝,阻塞对话会快速失败,而不会等待本提供方不负责的用户界面。
只有在 SDK `Query` 与受管的活动 CLI 句柄都已存在后,提供方才会发布运行。它会消费完整的 SDK 流;只有 `result` 消息具有 `subtype: "success"``is_error: false` 和非空白 `result`,且迭代器随后正常结束时,运行才会完成。所有 SDK 错误子类型、标记为错误的成功消息、结果缺失、迭代器失败、协议失败或进程失败都会成为 `error`。当权限拒绝或无人值守回调参与了该失败时,结果还可以携带由非交互权限决策负责的有界、非 assistant 诊断。SDK 的轮次、预算和结构化输出限制不表示 token 窗口耗尽,而且 SDK 没有原生的拒绝终止状态,因此本提供方不会产生 `max-tokens``refusal`。本地取消会胜出并成为 `aborted`,且不附带权限说明
只有在 SDK `Query` 与受管的活动 CLI 句柄都已存在后,提供方才会发布运行。它会消费完整的 SDK 流;只有 `result` 消息具有 `subtype: "success"``is_error: false` 和非空白 `result`,且迭代器随后正常结束时,运行才会完成。[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.md)负责所有非成功类别、阶段、进程结果,以及它们与参与失败的权限决定之间的顺序。本地取消会胜出并成为 `aborted`,且不附带这两类诊断事实
启动回滚和已发布运行的资源释放都会关闭 SDK query、中止该次运行的控制器、调用共享的进程树终止机制,并等待整棵进程树退出。`Query.close()` 表达优雅的协议关闭意图,但不能取代子进程责任方的退出证明。Query 关闭失败、进程失败和清理失败仍可彼此独立地观察
启动回滚和已发布运行的资源释放都会关闭 SDK query、中止该次运行的控制器、调用共享的进程树终止机制,并等待整棵进程树退出。`Query.close()` 表达优雅的协议关闭意图,但不能取代子进程责任方的退出证明。未发布失败只公开固定的 `query-start` 事实;已发布进程失败可以分别公开退出码与信号;独立清理拒绝则公开 `teardown`。原始 SDK、Host 与清理错误只保留在内部 cause 链和日志中,不进入诊断
带密钥 Claude Code e2e 直接使用官方 DeepSeek Claude Code 约定:仅在运行时提供的 DeepSeek 密钥会映射为 `ANTHROPIC_AUTH_TOKEN`,固定的官方基础 URL 会追加 `/anthropic`,主模型与 subagent 模型变量会选择文档所示的 DeepSeek 模型。该测试会启动生产提供方与真实 SDK 和 CLI,要求一个随机数作为完整答案,不会把任何凭据持久化到设置中,并等待所有受管句柄退出。
@@ -62,11 +62,11 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端
每个产品都负责覆盖所有分支的包测试、一项必跑的无密钥真实产品测试、一项 Loader 组合 e2e 和一项带密钥 DeepSeek e2e。无密钥产品层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。Codex Loader fixture 会公开两个命名 Codex 实例与工具;Claude Code Loader fixture 会公开默认 Codex 工具以及两个命名 Claude Code 实例与工具。两个 fixture 都包含通用 Job 控制工具,而且不会启动任何产品进程。带密钥层级会使用仅在运行时提供的密钥启动同一生产提供方与真实产品,要求从固定的 DeepSeek 官方服务取得唯一随机数,并再次证明完全停稳;仅当本地操作者未提供密钥时才会自行跳过,而受信任的 CI 会预检该 secret。
Codex 证据会锁定 `@openai/codex@0.147.0``codex-cli 0.147.0` 与六个平台 alias。其真实产品测试会观测包内 wrapper argv、确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、线程级 `never` 对环境中 `on-request` 的覆盖、自动评审启动、带安全诊断且不产生文件副作用的无人值守命令拒绝、测试拥有临时存储中的显式危险绕过写入、本地取消、wrapper/原生整棵进程树退出,以及载荷缺失时不回退宿主命令的失败
Codex 证据会锁定 `@openai/codex@0.147.0``codex-cli 0.147.0` 与六个平台 alias。其真实产品测试会观测包内 wrapper argv、确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、原生权限模式、测试拥有临时存储中的显式危险绕过写入,以及 wrapper/原生整棵进程树退出。独立 wrapper fixture 会证明载荷缺失时不回退宿主命令,两个命名实例会保留彼此独立的环境与模式,生产环境也不会从 `PATH` 解析宿主 `codex`。[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.md)负责 schema、失败、进程结果与最终呈现证据
带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。
Claude Code 证据会锁定 Agent SDK 0.3.220、Claude Code 2.1.220,以及八个 SDK 平台包的身份与版本。真实产品测试会让 SDK 选择已安装载荷,断言共享子进程 argv 以该包的原生 CLI 开头,并观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、安全提供方模式对继承的交互式宿主设置的覆盖、测试拥有临时目录中的拒绝写入与 bypass 写入、安全权限诊断、进程失败、本地取消和整棵进程树退出。单元覆盖会证明生产运行从不解析宿主 `PATH`、省略可执行文件覆盖直接转发 SDK 所选的 Windows `claude.exe` 而不经过 batch shim,并且在载荷缺失时原样暴露 SDK 错误且不回退宿主 CLI。这项证据证明锁定的官方 SDK/CLI 集成,而不证明与独立安装的 Claude 版本兼容。Loader 覆盖会通过各自的可选 Bundle patch 解析两个产品,且不会启动任一产品。
Claude Code 证据会锁定 Agent SDK 0.3.220、Claude Code 2.1.220八个 SDK 平台包。真实产品测试会让 SDK 选择已安装载荷,断言共享子进程 argv 以该包的原生 CLI 开头,并观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、原生权限模式、测试拥有范围内的拒绝写入与 bypass 写入,以及整棵进程树退出。包测试还会证明生产运行从不解析宿主 `PATH`、省略可执行文件覆盖,并直接转发 SDK 所选的 Windows `claude.exe` 而不经过 batch shim。这项证据证明锁定的官方 SDK/CLI 集成,而不证明与独立安装的 Claude 版本兼容[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.md)负责失败与进程结果证据。Loader 覆盖会通过各自的可选 Bundle patch 解析两个产品,且不会启动任一产品。
带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]``deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明整棵进程树退出,且测试不会直接调用 Messages API。
@@ -90,6 +90,6 @@ Claude Code 证据会锁定 Agent SDK 0.3.220、Claude Code 2.1.220,以及八
用户通过由 Profile 配置、并由官方产品集成支持的一次性工具进行委派。显式 Profile 安装与 host plane 提供方放置由[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责;命名实例身份与工具绑定由[命名实例决策](2026-08-18-product-subagent-named-instances.md)负责;按 Preset 暴露工具以及默认前台且可选通用 Job 的调度方式由[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责。本说明规定的提供方生命周期会保留原生设置与行为,而共享服务继续独占作业结算与进程树完全停稳的责任。
每次委派都要承担新建产品进程和独立模型上下文的开销。成功的产品载荷仍只有最终 assistant 文本;失败的产品运行可以另行公开共享安全诊断。后台调度还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。两个产品都使用 Bundle 锁定的平台 CLI,并保留原生账户与工作区设置以及所选提供方权限模式。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。
每次委派都要承担新建产品进程和独立模型上下文的开销。成功的产品载荷仍只有最终 assistant 文本;失败的产品运行可以另行公开共享安全诊断,其中包含由提供方拥有的权限事实,或锁定版本产品提供的结构化失败事实。后台调度还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。两个产品都使用 Bundle 锁定的平台 CLI,并保留原生账户与工作区设置以及所选提供方权限模式。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。
兼容性由包级单元测试覆盖率、无密钥真实产品回环测试、带密钥 DeepSeek 随机数测试、公开 Loader 组合、已构建包与 NodeNext 消费方检查、生成的文档与声明以及仓库 CI 矩阵共同锁定。更改受支持的产品基线或 DeepSeek 端点/模型基线时必须刷新这些事实;生产环境不会另行执行运行时版本探测。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md
2026-08-15-product-subagent-noninteractive-permissions.md: df1f0d9939e951f16070729615a3779f1f7c2ddc
2026-08-15-product-subagent-noninteractive-permissions.zh.md: 982b4409e08a506dec828db15c8c4aa5fcc36883
2026-08-15-product-subagent-noninteractive-permissions.md: 8788fba3492e08090dd038fc3e7377f6bd1e29cd
2026-08-15-product-subagent-noninteractive-permissions.zh.md: 6f254930151abce23f04de4f354bf57ad81bba61
@@ -44,9 +44,9 @@ The Provider overrides only those thread fields. `CODEX_HOME`, project configura
### Failure diagnostic
`SubagentResult` carries an optional `diagnostic` for provider-authored, non-assistant failure detail. A Provider removes tool inputs, file contents, environment values, credentials, and raw protocol payloads before producing it. The shared out-of-process result boundary limits the complete text to 4096 UTF-8 bytes and marks truncation without splitting a character.
`SubagentResult` carries an optional `diagnostic` for provider-authored, non-assistant failure detail. A Provider removes tool inputs, file contents, environment values, credentials, and raw protocol payloads before producing it. The shared out-of-process result boundary limits the complete text to 4096 UTF-8 bytes and marks truncation without splitting a character. The [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns non-permission product categories, lifecycle stages, and process outcomes carried by the same field.
Each product records only the effective mode, request category, unattended decision, and a fixed safe reason. Claude Code derives those facts from SDK callbacks and `permission_denied` messages. Codex derives them from app-server requests, declined items, `sandboxError`, and two fixed permission signatures in a bounded stderr tail; raw stderr is still forwarded to the Host but never copied into the diagnostic. A successful result returns only the strict final answer; local cancellation remains `aborted` without permission detail; an unpublished startup failure still rejects `start()`. When a permission fact contributes to a published run that settles as `error`, the Provider attaches the diagnostic without adding it to assistant output, structured output, or `subagent/end.lastAssistantMessage`.
Each product's permission fact contains only the effective mode, request category, unattended decision, and a fixed safe reason. Claude Code derives those facts from SDK callbacks and `permission_denied` messages. Codex derives them from app-server requests, declined items, `sandboxError`, and two fixed permission signatures in a bounded stderr tail; raw stderr is still forwarded to the Host but never copied into the diagnostic. Both Providers place their structured failure line before the latest contributing permission fact. A successful result returns only the strict final answer; local cancellation remains `aborted` without permission detail; an unpublished startup failure still rejects `start()`. The Provider never adds either diagnostic fact to assistant output, structured output, or `subagent/end.lastAssistantMessage`.
The foreground consumer presents the stop-reason headline, then the optional diagnostic, then any partial assistant output. The one-shot background adapter stores the same diagnostic beside the stop reason in the failed Job detail. Providers that omit the field retain their previous behavior.
@@ -63,7 +63,7 @@ The foreground consumer presents the stop-reason headline, then the optional dia
## Verification
Package tests pin every allowed and rejected Config value, the exact SDK and app-server field mappings, dangerous confirmations, unattended terminal responses, diagnostic sanitization and UTF-8 bound, successful-result omission, concurrent-run isolation, foreground ordering, Job detail, stderr observer disposal, and process cleanup. The real Claude Agent SDK/CLI fixture proves its safe default, restricted denial, explicit bypass, and whole-tree quiescence. The real Codex app-server fixture proves that thread-level `never` overrides ambient `on-request`, automatic review starts, dangerous bypass writes only inside suite-owned temporary storage, fixed stderr signatures produce safe diagnostics, and the wrapper/native tree exits. Loader composition proves non-default modes can be published without starting either product, and keyless ACP snapshots record the shared diagnostic presentation while the model-facing product tool schemas contain no permission parameter.
Package tests pin every allowed and rejected Config value, the exact SDK and app-server field mappings, dangerous confirmations, unattended terminal responses, diagnostic sanitization and UTF-8 bound, successful-result omission, concurrent-run isolation, foreground ordering, Job detail, stderr observer disposal, and process cleanup. The real Claude Agent SDK/CLI fixture proves its safe default, restricted denial, explicit bypass, and whole-tree quiescence. The real Codex app-server fixture proves that thread-level `never` overrides ambient `on-request`, automatic review starts, dangerous bypass writes only inside suite-owned temporary storage, fixed stderr signatures produce safe diagnostics, and the wrapper/native tree exits. Loader composition proves non-default modes can be published without starting either product, and the keyless ACP snapshot records each product's failure diagnostic through foreground and Job presentation while the model-facing product tool schemas contain no permission parameter.
## Alternatives considered
@@ -83,6 +83,6 @@ Package tests pin every allowed and rejected Config value, the exact SDK and app
Profiles can select each product's native restricted, automatic, planning/edit-accepting where supported, or bypass behavior before the Provider starts, while both safe defaults never ask a person. Broader modes remain explicit deployment choices and retain their native sandbox consequences.
Permission failures become visible to both foreground parents and one-shot background Jobs without turning infrastructure text into an assistant answer. That diagnostic can enter model context, Job notices, API projections, and Job UI through the ordinary consumer paths, so the Provider must sanitize and bound it before result settlement.
Permission failures become visible to both foreground parents and one-shot background Jobs without turning infrastructure text into an assistant answer. The same field can also carry the separately owned structured failure facts. It can enter model context, Job notices, API projections, and Job UI through the ordinary consumer paths, so the Provider must sanitize and bound the complete text before result settlement.
The change adds no product session persistence, human approval channel, dynamic permission operation, progress stream, retry policy, or rollback. Other Providers remain valid without producing a diagnostic or exposing a permission-mode Config.
@@ -44,9 +44,9 @@ Codex 默认使用 `never`,并接受 Codex 0.147.0 公开的三种原生非交
### 失败诊断
`SubagentResult` 携带可选的 `diagnostic`,用于提供方产生且不属于 assistant 内容的失败说明。提供方在生成它之前会排除工具输入、文件内容、环境值、凭证与原始协议载荷。共享的进程外结果边界会把完整文本限制在 4096 个 UTF-8 字节以内,并在不切断字符的前提下标记截断。
`SubagentResult` 携带可选的 `diagnostic`,用于提供方产生且不属于 assistant 内容的失败说明。提供方在生成它之前会排除工具输入、文件内容、环境值、凭证与原始协议载荷。共享的进程外结果边界会把完整文本限制在 4096 个 UTF-8 字节以内,并在不切断字符的前提下标记截断。[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.md)负责由同一字段承载的非权限产品类别、生命周期阶段与进程结果。
每个产品都只记录有效模式、请求类别、无人值守决定与固定的安全原因。Claude Code 从 SDK 回调和 `permission_denied` 消息取得这些事实。Codex 从 app-server 请求、被拒绝的 item、`sandboxError` 与每次运行有界 stderr 尾部中的两个固定权限签名取得事实;原始 stderr 仍会转发给 Host,但绝不会复制进诊断。成功结果只返回严格的最终答案;本地取消仍以 `aborted` 结算且不附带权限说明;未发布的启动失败仍会拒绝 `start()`当一项权限事实参与了已经发布、最终以 `error` 结算的运行时,提供方会附加诊断,但不会把它写入 assistant 输出、结构化输出或 `subagent/end.lastAssistantMessage`
每个产品的权限事实都只包含有效模式、请求类别、无人值守决定与固定的安全原因。Claude Code 从 SDK 回调和 `permission_denied` 消息取得这些事实。Codex 从 app-server 请求、被拒绝的 item、`sandboxError` 与每次运行有界 stderr 尾部中的两个固定权限签名取得事实;原始 stderr 仍会转发给 Host,但绝不会复制进诊断。两个提供方都会把结构化失败行放在最新参与失败的权限事实之前。成功结果只返回严格的最终答案;本地取消仍以 `aborted` 结算且不附带权限说明;未发布的启动失败仍会拒绝 `start()`提供方绝不会把任一诊断事实写入 assistant 输出、结构化输出或 `subagent/end.lastAssistantMessage`
前台消费方依次呈现终止原因标题、可选诊断和任何部分 assistant 输出。一次性后台适配器会在失败 Job 的 detail 中,把同一诊断与终止原因一起保存。没有填写该字段的提供方保持原有行为。
@@ -63,7 +63,7 @@ Codex 默认使用 `never`,并接受 Codex 0.147.0 公开的三种原生非交
## Verification
包测试固定所有允许与拒绝的 Config 值、准确的 SDK 与 app-server 字段映射、危险确认、无人值守终态、诊断脱敏与 UTF-8 上限、成功结果不携带诊断、并发运行隔离、前台顺序、Job detail、stderr observer 释放和进程清理。真实 Claude Agent SDK/CLI fixture 证明其安全默认、受限拒绝、显式 bypass 与整棵进程树完全停稳。真实 Codex app-server fixture 证明线程级 `never` 覆盖环境中的 `on-request`、自动评审可以启动、危险绕过只在测试拥有的临时存储中写入、固定 stderr 签名产生安全诊断,而且 wrapper/native 进程树会退出。Loader 组装证明非默认模式可以在不启动任一产品的情况下发布;无密钥 ACP snapshot 则记录共享诊断呈现,同时面向模型的产品工具 schema 不包含权限参数。
包测试固定所有允许与拒绝的 Config 值、准确的 SDK 与 app-server 字段映射、危险确认、无人值守终态、诊断脱敏与 UTF-8 上限、成功结果不携带诊断、并发运行隔离、前台顺序、Job detail、stderr observer 释放和进程清理。真实 Claude Agent SDK/CLI fixture 证明其安全默认、受限拒绝、显式 bypass 与整棵进程树完全停稳。真实 Codex app-server fixture 证明线程级 `never` 覆盖环境中的 `on-request`、自动评审可以启动、危险绕过只在测试拥有的临时存储中写入、固定 stderr 签名产生安全诊断,而且 wrapper/native 进程树会退出。Loader 组装证明非默认模式可以在不启动任一产品的情况下发布;无密钥 ACP snapshot 则记录每个产品的失败诊断如何经过前台与 Job 呈现,同时面向模型的产品工具 schema 不包含权限参数。
## Alternatives considered
@@ -83,6 +83,6 @@ Codex 默认使用 `never`,并接受 Codex 0.147.0 公开的三种原生非交
Profile 可以在提供方启动前选择各产品原生的受限、自动、在产品支持时仅规划/编辑放行,或 bypass 行为,而两个安全默认值都绝不会询问人员。更宽松的模式仍是显式部署选择,并保留其原生沙箱后果。
权限失败会同时到达前台父 agent 和一次性后台 Job,且不会把基础设施文本伪装成 assistant 回答。该诊断可以沿普通消费路径进入模型上下文、Job 通知、API 投影与 Job UI,因此提供方必须在结果结算前完成脱敏和限长。
权限失败会同时到达前台父 agent 和一次性后台 Job,且不会把基础设施文本伪装成 assistant 回答。同一字段还可以承载由另一项决策负责的结构化失败事实。它可以沿普通消费路径进入模型上下文、Job 通知、API 投影与 Job UI,因此提供方必须在结果结算前对完整文本完成脱敏和限长。
本改动不增加产品会话持久化、人工审批通道、动态权限操作、进度流、重试策略或回滚。其他提供方无需产生诊断或公开权限模式 Config,仍然保持合法。
@@ -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-18-product-subagent-failure-facts.md
2026-08-18-product-subagent-failure-facts.md: 50d8e918f288a6b8a9b90474499b2ed20731643f
2026-08-18-product-subagent-failure-facts.zh.md: 7dc5a73637c90a1ca1123c86d754f95c68498fd9
@@ -0,0 +1,87 @@
# Agent Note: Product subagents expose bounded structured failure facts
Status: implemented
English | [中文](2026-08-18-product-subagent-failure-facts.zh.md)
## Problem
The [Claude Code and Codex product providers](2026-08-04-claude-code-and-codex-subagent-backends.md) receive structured product failures, but a published run historically flattened most of them to the shared `error` stop reason. Product logs retained detail that the foreground parent and a [one-shot background Job](2026-08-12-product-subagent-one-shot-background-tasks.md) could not use to distinguish a product limit, an execution failure, or an early process exit.
Copying SDK error text, app-server payloads, or stderr into the result would expose task text, paths, environment values, credentials, or product internals. Adding shared error fields would also make the provider-neutral [subagent seam](2026-06-21-subagent-capability-seam.md) own product version vocabularies that change independently.
## Decision
Each product Provider owns the mapping from its pinned official error union, current operation, and managed process outcome to one fixed safe diagnostic line. `SubagentResult` remains unchanged: consumers receive the existing bounded `diagnostic` string and do not parse its product-private fields.
### Safe diagnostic
The structured line has this fixed order:
```text
Product subagent failure (product: <product>; stage: <stage>; category: <category>; HTTP status: <status>; exit code: <code>; signal: <signal>)
```
The Provider omits unavailable optional fields. Exit code and signal are independent facts and are each retained when observed. A contributing permission decision from the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) follows the structured line; the latest safe permission fact remains operation-local. The shared result boundary limits the complete text to 4096 UTF-8 bytes.
Successful results and local cancellation expose no failure fact. Raw product errors, stderr, tool input, paths, environment values, credentials, and protocol payloads never enter the diagnostic. Startup and cleanup rejections use the same safe line in their Error message. Original failures remain on internal cause chains; Provider Host logs and forwarded stderr remain product-local observation only.
### Claude Code facts
Agent SDK 0.3.220 defines four error subtypes: `error_during_execution`, `error_max_turns`, `error_max_budget_usd`, and `error_max_structured_output_retries`. The Claude Code Provider preserves each exact subtype as the category while keeping the shared stop reason `error`. An error-marked or blank success uses `invalid-success`, a missing result uses `missing-result`, a process exit before an SDK terminal result uses `process-exit`, and an unrecognized value or exception uses `unknown` without copying the value.
| Stage | Owned operation | Observable failure |
| --- | --- | --- |
| `query-start` | SDK query construction, native platform-payload startup, and unpublished rollback | `start()` rejects with fixed safe facts and any process outcome observed before rollback |
| `query-run` | Published SDK message iteration and strict terminal-result validation | The run resolves as `error` with the exact known subtype or a fixed result category |
| `process` | Managed CLI exits before the SDK supplies a terminal result | The run resolves as `error` with `process-exit` and the available exit code and signal |
| `teardown` | Query close and managed process-tree release | `dispose()` rejects independently with fixed safe facts after cleanup still reaches its final exit wait |
### Codex facts
Codex app-server 0.147.0 defines eleven string categories and five object variants. The Provider preserves `contextWindowExceeded`, `sessionBudgetExceeded`, `usageLimitExceeded`, `serverOverloaded`, `cyberPolicy`, `internalServerError`, `unauthorized`, `badRequest`, `threadRollbackFailed`, `sandboxError`, and `other`. It also preserves `httpConnectionFailed`, `responseStreamConnectionFailed`, `responseStreamDisconnected`, `responseTooManyFailedAttempts`, and `activeTurnNotSteerable`; the four connection/stream variants retain numeric `httpStatusCode`, while the active-turn variant does not expose `turnKind`. Unknown strings, objects with another variant set, malformed values, and unclassified exceptions use `unknown`.
| Stage | Owned operation | Observable failure |
| --- | --- | --- |
| `initialize` | App-server spawn and initialize/initialized handshake | `start()` rejects with fixed safe facts and any process outcome already observed |
| `thread-start` | Ephemeral `thread/start` request and response validation | `start()` rejects with the thread stage and any available process outcome |
| `turn-start` | Published `turn/start` request, provisional ids, and early frames | The run resolves as `error` with a safe unknown fallback when no structured category exists |
| `turn` | Terminal notification, final-answer selection, and error-info mapping | The complete category and optional HTTP status reach the non-completed result |
| `process` | Managed app-server exits before another terminal path settles | The run resolves as `error` with `process-exit` and any available code and signal |
| `teardown` | Wire close and process-tree release | `dispose()` rejects independently; startup rollback aggregation exposes both startup and teardown lines |
`contextWindowExceeded` remains `max-tokens`; every other known or unknown Codex category remains `error`, and `cyberPolicy` does not become `refusal`.
### Ownership and lifecycle
| Fact or resource | Owner | Consumer behavior |
| --- | --- | --- |
| Product error category | Pinned official SDK or app-server version | The Provider maps only the declared structured union and uses `unknown` outside it |
| Current failure stage | Product Provider operation | Derived at the failure site; never persisted or used as a recovery state |
| Exit code and signal | `dsh-subprocess` process handle | The Provider displays observed values without inferring missing ones |
| Diagnostic bytes and delivery | `dsh-subagent`, foreground tool, and Job runtime | The same bounded text is presented separately from assistant output in both scheduling modes |
| Raw product failure | Product runtime, internal cause chain, and Host observation | It remains internal and never becomes model-visible result text |
## Verification
Claude Code package tests pin all four SDK subtypes, invalid success, missing result, unknown values and exceptions, all four stages, independent exit code and signal fields, permission-fact ordering, sanitization, successful-result and cancellation omission, concurrent-run isolation, and cleanup completion. Codex package tests pin all sixteen error-info variants, HTTP status presence and absence, all six stages, unknown fallback, stop-reason preservation, permission ordering, sanitization, cancellation, concurrency, and cleanup aggregation. The real SDK/CLI fixture produces an actual Claude `error_max_turns`; the real app-server fixture produces an actual Codex `internalServerError`; both fixtures cover process/protocol failure and whole-tree quiescence. The keyless ACP snapshot records each product's exact diagnostic in foreground error output, a background completion notice, and `job_output`.
## Alternatives considered
**Return raw SDK errors, app-server payloads, or stderr.** These values can contain commands, paths, workspace content, environment values, credentials, or upstream prose. A fixed allowlisted mapping preserves actionable facts without expanding the model-visible trust boundary.
**Add a shared product-error enum or structured result fields.** Claude Code and Codex version their error unions independently. A shared enum would duplicate those authorities and force unrelated Providers and consumers to track product releases.
**Parse generic stderr and exception messages.** Free-form text is neither stable nor safe. Only pinned structured product fields and the managed process outcome qualify as diagnostic input.
**Persist stages or add a recovery controller.** The stage is derived from the current call site only when a failure is reported. Persistence, retries, resume, and remediation need separate ownership and user contracts.
**Map product limits to new shared stop reasons.** Claude Code turn and budget limits are not token-window exhaustion, and an error category does not establish refusal semantics. Existing stop reasons remain unchanged.
## Consequences
The parent can distinguish important Claude Code limits and Codex budget, usage, service, policy, request, connection, stream, rollback, sandbox, and active-turn failures without receiving raw product text. Foreground and background scheduling preserve the same fact because both consume one `SubagentResult`.
The diagnostic is display text rather than a new public protocol. Callers may present it but must not branch on its punctuation or product-private category names. A pinned product-version upgrade must update the Provider mapping and evidence when its official error union changes.
This decision adds no product session persistence, retry policy, recovery state, stderr classifier, authentication or configuration taxonomy, progress stream, or human interaction path.
@@ -0,0 +1,87 @@
# Agent Note: 产品 subagent 公开有界结构化失败事实
Status: implemented
[English](2026-08-18-product-subagent-failure-facts.md) | 中文
## Problem
[Claude Code 与 Codex 产品提供方](2026-08-04-claude-code-and-codex-subagent-backends.md)会收到结构化产品失败,但已发布运行以往会把其中大多数压成共享的 `error` 终止原因。产品日志保留了细节,前台父 agent 与[一次性后台 Job](2026-08-12-product-subagent-one-shot-background-tasks.md)却无法据此区分产品限制、执行失败或进程提前退出。
若把 SDK 错误文本、app-server payload 或 stderr 复制进结果,就会暴露任务文本、路径、环境值、凭证或产品内部信息。若增加共享错误字段,又会让提供方无关的 [subagent seam](2026-06-21-subagent-capability-seam.md)拥有彼此独立变化的产品版本词汇。
## Decision
每个产品提供方分别拥有从锁定版本官方错误联合、当前操作和受管进程结果到一行固定安全诊断的映射。`SubagentResult` 保持不变:消费方仍接收现有的有界 `diagnostic` 字符串,而且不解析其中由产品私有的字段。
### 安全诊断
结构化行采用以下固定顺序:
```text
Product subagent failure (product: <product>; stage: <stage>; category: <category>; HTTP status: <status>; exit code: <code>; signal: <signal>)
```
提供方会省略不可用的可选字段。退出码与信号是相互独立的事实,只要已观测到就分别保留。来自[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)且参与失败的权限决定会跟在结构化行之后;最新的安全权限事实仍只属于当前操作。共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。
成功结果与本地取消都不公开失败事实。原始产品错误、stderr、工具输入、路径、环境值、凭证和协议 payload 绝不会进入诊断。启动与清理拒绝会在 Error 消息中使用同一安全行。原始失败保留在内部 cause 链中;提供方 Host 日志与转发的 stderr 也只作为产品本地观测。
### Claude Code 事实
Agent SDK 0.3.220 定义四种错误子类型:`error_during_execution``error_max_turns``error_max_budget_usd``error_max_structured_output_retries`。Claude Code 提供方会把每种准确子类型保留为类别,同时维持共享终止原因 `error`。标记为错误或内容空白的成功消息使用 `invalid-success`,缺失结果使用 `missing-result`,SDK 给出终态结果前发生的进程退出使用 `process-exit`,无法识别的值或异常使用 `unknown`,且不会复制原值。
| 阶段 | 归属操作 | 可观察失败 |
| --- | --- | --- |
| `query-start` | SDK query 构造、原生平台载荷启动与未发布回滚 | `start()` 以固定安全事实和回滚前已观测到的进程结果拒绝 |
| `query-run` | 已发布 SDK 消息迭代与严格终态结果校验 | 运行以 `error` 兑现,并携带准确已知子类型或固定结果类别 |
| `process` | SDK 提供终态结果之前受管 CLI 已退出 | 运行以 `error` 兑现,并携带 `process-exit` 以及可用的退出码和信号 |
| `teardown` | Query 关闭与受管进程树释放 | `dispose()` 独立拒绝并携带固定安全事实,同时清理仍会完成最终退出等待 |
### Codex 事实
Codex app-server 0.147.0 定义十一种字符串类别与五种对象 variant。提供方会保留 `contextWindowExceeded``sessionBudgetExceeded``usageLimitExceeded``serverOverloaded``cyberPolicy``internalServerError``unauthorized``badRequest``threadRollbackFailed``sandboxError``other`。它还会保留 `httpConnectionFailed``responseStreamConnectionFailed``responseStreamDisconnected``responseTooManyFailedAttempts``activeTurnNotSteerable`;四种连接/stream variant 会保留数值 `httpStatusCode`,而 active-turn variant 不公开 `turnKind`。未知字符串、同时含其他 variant 的对象、格式错误值与未分类异常统一使用 `unknown`
| 阶段 | 归属操作 | 可观察失败 |
| --- | --- | --- |
| `initialize` | App-server spawn 与 initialize/initialized 握手 | `start()` 以固定安全事实和已经观测到的进程结果拒绝 |
| `thread-start` | 临时 `thread/start` 请求与响应校验 | `start()` 以线程阶段和可用进程结果拒绝 |
| `turn-start` | 已发布 `turn/start` 请求、暂定 id 与早到 frame | 没有结构化类别时,运行以 `error` 和安全 unknown 回退兑现 |
| `turn` | 终态通知、最终答案选择与 error-info 映射 | 完整类别与可选 HTTP status 进入非完成结果 |
| `process` | 受管 app-server 在另一终态路径结算前退出 | 运行以 `error` 兑现,并携带 `process-exit` 以及可用的退出码与信号 |
| `teardown` | Wire 关闭与进程树释放 | `dispose()` 独立拒绝;启动回滚聚合会同时公开启动与 teardown 两行 |
`contextWindowExceeded` 仍是 `max-tokens`;其他所有已知或未知 Codex 类别仍是 `error``cyberPolicy` 不会变成 `refusal`
### 所有权与生命周期
| 事实或资源 | Owner | 消费方行为 |
| --- | --- | --- |
| 产品错误类别 | 锁定版本的官方 SDK 或 app-server | 提供方只映射已声明的结构化联合,并对联合外值使用 `unknown` |
| 当前失败阶段 | 产品提供方操作 | 只在失败点派生;绝不持久化,也不作为恢复状态 |
| 退出码与信号 | `dsh-subprocess` 进程句柄 | 提供方展示已观测值,不推测缺失值 |
| 诊断字节与送达 | `dsh-subagent`、前台工具与 Job 运行时 | 两种调度模式都把同一份有界文本与 assistant 输出分开呈现 |
| 原始产品失败 | 产品运行时、内部 cause 链与 Host 观测 | 只保留在内部,绝不成为模型可见的结果文本 |
## Verification
Claude Code 包测试固定四种 SDK 子类型、无效成功、缺失结果、未知值与异常、四个阶段、相互独立的退出码与信号字段、权限事实顺序、脱敏、成功结果与取消时省略诊断、并发运行隔离和清理完成。Codex 包测试固定全部十六种 error-info variant、HTTP status 存在与缺失、六个阶段、unknown 回退、终止原因保持不变、权限顺序、脱敏、取消、并发与清理聚合。真实 SDK/CLI fixture 会产生真实的 Claude `error_max_turns`,真实 app-server fixture 会产生真实的 Codex `internalServerError`;两个 fixture 都覆盖进程/协议失败与整棵进程树完全停稳。无密钥 ACP snapshot 会在前台错误输出、后台完成通知和 `job_output` 中记录两个产品各自的准确诊断。
## Alternatives considered
**返回原始 SDK 错误、app-server payload 或 stderr。** 这些值可能包含命令、路径、工作区内容、环境值、凭证或上游文本。固定白名单映射可以保留可操作事实,同时不扩大模型可见的信任边界。
**增加共享产品错误 enum 或结构化结果字段。** Claude Code 与 Codex 各自独立版本化错误联合。共享 enum 会复制这些权威,并迫使无关提供方和消费方跟随产品版本。
**解析通用 stderr 与异常消息。** 自由文本既不稳定也不安全。只有锁定版本产品提供的结构化字段和受管进程结果可以成为诊断输入。
**持久化阶段或增加恢复控制器。** 阶段只在报告失败时从当前调用点派生。持久化、重试、resume 与修复需要独立的所有权和用户约定。
**把产品限制映射为新的共享终止原因。** Claude Code 的轮次和预算限制并不表示 token 窗口耗尽,错误类别也不能证明拒绝语义。既有终止原因保持不变。
## Consequences
父 agent 可以区分重要的 Claude Code 限制,以及 Codex 预算、用量、服务、策略、请求、连接、stream、回滚、sandbox 和 active-turn 失败,而不会收到原始产品文本。前台与后台调度会保留同一事实,因为二者都消费同一个 `SubagentResult`
诊断只是展示文本,不是新的公开协议。调用方可以呈现它,但不得根据其标点或产品私有类别名称进行分支。锁定产品版本升级并改变官方错误联合时,必须同步更新提供方映射与证据。
本决策不增加产品会话持久化、重试策略、恢复状态、stderr 分类器、身份验证或配置分类体系、进度流或人工交互路径。
@@ -0,0 +1,336 @@
// Web e2e scenario: with the feedback note editor open, the assistant IconActions
// row stays one intact line (no wrapping, nothing pushed out), and the note
// editor floats above the transcript in a popover that escapes the conversation
// column's overflow clip and stays inside the viewport.
//
// The hazard this pins: a slot-contributed note editor (260px textarea plus
// Save and Cancel) cannot fit the shared IconActions row at ANY viewport, and an
// inline expansion made the row wider than the column — full-screen desktop
// included — so the branch action and the clock were pushed out of view by later
// flex items. The fix is to not mount the editor in the row at all: it is a
// popover portaled to document.body and fixed-positioned from the note trigger's
// rect, so the row keeps its single 28px line of icons and the trigger, and the
// panel cannot be cropped by the column's overflow because it lives outside it.
//
// The sweep records, per viewport, whether the open editor keeps the actions row
// on one line with zero overflow, whether the panel is outside the column (proof
// it escapes the clip), whether the panel stays inside the viewport (proof the
// clamp works), and whether it sits by its trigger. All relations, no absolute
// pixels: the column width follows the viewport, the sidebar, and the platform's
// scrollbar, so a golden carrying pixels would document the platform, not the
// behavior.
//
// Zero model calls: a settled transcript is cold-seeded, so nothing streams.
import { readFile } from 'node:fs/promises'
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'
import {
compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/message-feedback-layout', import.meta.url))
/**
* Committed golden of the popover relations at every stop. Booleans and counts
* only, never absolute coordinates.
*/
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
const MODE = webSnapshotMode()
/** Borrowed read-only: this scenario needs any settled assistant message to rate. */
const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
const SEED_ID = 'message-feedback-layout-e2e'
/** Viewport widths from full-screen desktop down to a narrow window. */
const WIDTHS = [1680, 1280, 1024, 900, 700, 600]
/** One viewport stop: how the row reads with the note editor closed and open, plus the popover's own relations. */
export interface PopoverMetrics {
/** Viewport width the stop was measured at. */
width: number
/** The row's scrollable overflow with the note editor closed (natural row width). */
rowOverflowClosed: number
/** The row's scrollable overflow with the note editor open; must equal the closed value. */
rowOverflowOpen: number
/** Flex lines the row occupies with the note editor open; the editor must not reflow it. */
rowLines: number
/** Row items whose right edge escapes the column, editor closed. */
itemsOutsideColumnClosed: number
/** Row items whose right edge escapes the column, editor open; must equal the closed value. */
itemsOutsideColumnOpen: number
/** True when the portaled panel is NOT inside the column (escapes its overflow clip). */
panelOutsideColumn: boolean
/** True when the panel lies fully inside the viewport (the clamp holds). */
panelWithinViewport: boolean
/** Horizontal separation between the panel's left edge and the note trigger's, in px. */
panelToTriggerGap: number
}
/**
* Measure the feedback row (and the open popover, when present) at the current
* viewport. The same reader serves the closed and open readings so the two
* sides differ only by whether the editor is open.
* @param page - the page under test.
* @param width - the viewport width already applied, recorded with the reading.
* @param editorOpen - true to also read the popover's relations; throws if it is absent.
* @returns the stop's relations.
*/
function measurePopover(page: Page, width: number, editorOpen: boolean): Promise<PopoverMetrics> {
return page.evaluate(({ viewportWidth, open }) => {
const rated = document.querySelector<HTMLElement>('button[aria-label="Remove rating"]')
if (rated === null) throw new Error('no rated feedback control in the DOM')
const row = rated.parentElement?.closest<HTMLElement>('div[class*="actions"]') ?? null
if (row === null) throw new Error('the IconActions row is not an ancestor of the feedback control')
const trigger = row.querySelector<HTMLElement>('button[aria-haspopup="dialog"]')
if (trigger === null) throw new Error('the note trigger is not in the row')
/**
* The real flex items of the row. A slot contributor (the feedback strip)
* arrives as a `display: contents` wrapper (the `assistant-actions` slot
* renders inside a transparent `data-slot` div), which reports an all-zero
* rect; a zero box would be miscounted as a phantom flex line. The actual
* items are the boxes inside it.
* @param element - the row whose items to read.
* @returns the real flex-item boxes, in flex/DOM order.
*/
const flexItemBoxes = (element: HTMLElement): DOMRect[] => {
const boxes: DOMRect[] = []
for (const child of Array.from(element.children)) {
const el = child as HTMLElement
const rect = el.getBoundingClientRect()
if (el.style.display === 'contents') {
boxes.push(...flexItemBoxes(el))
} else if (rect.height > 0 && rect.width > 0) {
boxes.push(rect)
}
}
return boxes
}
/**
* Group items into flex lines by overlapping vertical extent.
* @param boxes - the row items' boxes, in DOM order.
* @returns the number of distinct lines.
*/
const countFlexLines = (boxes: DOMRect[]): number => {
const centres: number[] = []
for (const box of boxes) {
const centre = box.top + box.height / 2
if (!centres.some(known => Math.abs(known - centre) <= box.height / 2)) centres.push(centre)
}
return centres.length
}
const column = row.closest<HTMLElement>('[data-conversation-scroll]')
const columnRight = (column?.getBoundingClientRect().left ?? 0) + (column?.clientWidth ?? 0)
const itemRects = flexItemBoxes(row)
// A half-pixel tolerance: subpixel layout puts a contained edge a fraction
// over the boundary on some device scale factors.
const itemsOutsideColumn = itemRects.filter(box => box.right > columnRight + 0.5).length
// The editor is a portal, so the row measures identically whether the
// editor is open or not; the closed/open fields differ by call so the sweep
// can assert a zero delta on them.
const overflow = row.scrollWidth - row.clientWidth
let builder: {
panelOutsideColumn: boolean
panelWithinViewport: boolean
panelToTriggerGap: number
}
if (!open) {
builder = { panelOutsideColumn: true, panelWithinViewport: true, panelToTriggerGap: 0 }
} else {
const panel = document.body.querySelector<HTMLElement>('[role="dialog"]')
if (panel === null) throw new Error('the note popover is not open')
const panelBox = panel.getBoundingClientRect()
const triggerBox = trigger.getBoundingClientRect()
const vw = window.innerWidth
const vh = window.innerHeight
builder = {
// The panel portals out of the column, so the clip cannot reach it.
panelOutsideColumn: column === null ? true : !column.contains(panel),
panelWithinViewport:
panelBox.left >= -0.5
&& panelBox.right <= vw + 0.5
&& panelBox.top >= -0.5
&& panelBox.bottom <= vh + 0.5,
// The panel is fixed from the trigger's left, so a zero gap says it is
// anchored; a clamp can only widen it.
panelToTriggerGap: Math.abs(panelBox.left - triggerBox.left),
}
}
return {
width: viewportWidth,
rowOverflowClosed: overflow,
rowOverflowOpen: overflow,
rowLines: countFlexLines(itemRects),
itemsOutsideColumnClosed: itemsOutsideColumn,
itemsOutsideColumnOpen: itemsOutsideColumn,
...builder,
}
}, { viewportWidth: width, open: editorOpen })
}
/**
* Render the golden body: one line per stop, relations and counts only. The
* row-overflow and outside-column readings are deltas (open minus closed) so
* the golden records that opening the editor leaves the row untouched, not an
* absolute count that many unrelated controls could move.
* @param stops - the measured stops, in sweep order.
* @returns the golden body, without a trailing newline.
*/
function renderGeometry(stops: PopoverMetrics[]): string {
return [
'# Assistant actions row with the feedback note popover open',
'',
'| viewport | row overflow delta | row lines | items-outside delta '
+ '| panel outside the column | panel within the viewport | panel-to-trigger gap |',
'| --- | --- | --- | --- | --- | --- | --- |',
...stops.map(stop => `| ${String(stop.width)}px | ${String(stop.rowOverflowOpen - stop.rowOverflowClosed)}px `
+ `| ${String(stop.rowLines)} | ${String(stop.itemsOutsideColumnOpen - stop.itemsOutsideColumnClosed)} `
+ `| ${String(stop.panelOutsideColumn)} | ${String(stop.panelWithinViewport)} `
+ `| ${String(stop.panelToTriggerGap)}px |`),
].join('\n')
}
describe('web e2e: the feedback note editor floats above the column', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
await seedSession(scaffold, await readFile(SEED, 'utf8'), SEED_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser, 900)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 180_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
/**
* Open the seeded transcript. The first treeitem is the collapsible group
* row; the session itself is the row beneath it.
* @returns nothing.
*/
async function openSeededSession(): Promise<void> {
const groupRow = page.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
if (await groupRow.getAttribute('aria-expanded') !== 'true') await groupRow.click()
const sessionRow = page.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 15_000 })
await sessionRow.click()
}
/**
* Resize to a viewport and read the row once its width stops moving. The
* frame eases its column tracks, so reading straight after a resize can
* report the previous viewport's relation.
* @param width - viewport width to settle at.
* @param editorOpen - whether the note editor is currently open; reads the popover relations when so.
* @returns the row's (and popover's) readings at that width.
*/
const settleAt = async (width: number, editorOpen: boolean): Promise<PopoverMetrics> => {
await page.setViewportSize({ width, height: 900 })
let previous = -1
await expect.poll(async () => {
const current = await page.evaluate(() =>
document.querySelector('[data-conversation-scroll]')?.clientWidth ?? -1)
const settled = current === previous
previous = current
return settled
}, { timeout: 10_000 }).toBe(true)
// The popover is JS-positioned from the trigger rect and re-places on
// resize/scroll, so once the column width stops moving we nudge it to the
// final layout; otherwise the panel can sit at a transient position from
// mid-resize and the anchor reading would be off.
await page.evaluate(() => window.dispatchEvent(new Event('resize')))
return measurePopover(page, width, editorOpen)
}
/**
* Rate a message, then for every stop read the row once with the note editor
* closed and once with it open, handing the SAME measured readings to both
* assertions so the golden and the assertions describe one measurement
* rather than two runs that could disagree.
* @returns the stops in {@link WIDTHS} order.
*/
let swept: Promise<PopoverMetrics[]> | undefined
const sweep = (): Promise<PopoverMetrics[]> => {
swept ??= (async () => {
await openSeededSession()
await page.getByText('DONE', { exact: true }).waitFor({ timeout: 30_000 })
// The controller defers its list read to the first hover or focus, so the
// strip has to be touched before it can be rated.
const like = page.getByRole('button', { name: 'Good response' }).first()
await like.waitFor({ timeout: 30_000 })
await like.scrollIntoViewIfNeeded()
await like.hover()
await like.click()
await page.getByRole('button', { name: 'Remove rating' }).first()
.waitFor({ timeout: 15_000 })
const noteTrigger = page.getByRole('button', { name: 'Add a note' }).first()
const stops: PopoverMetrics[] = []
for (const width of WIDTHS) {
// Reset to the closed baseline at each stop before opening.
if (await noteTrigger.getAttribute('aria-expanded') === 'true') await noteTrigger.click()
const closed = await settleAt(width, false)
await page.getByRole('button', { name: 'Add a note' }).first().click()
await page.getByRole('dialog').waitFor({ timeout: 10_000 })
const open = await settleAt(width, true)
stops.push({
width,
rowOverflowClosed: closed.rowOverflowClosed,
rowOverflowOpen: open.rowOverflowOpen,
rowLines: open.rowLines,
itemsOutsideColumnClosed: closed.itemsOutsideColumnClosed,
itemsOutsideColumnOpen: open.itemsOutsideColumnOpen,
panelOutsideColumn: open.panelOutsideColumn,
panelWithinViewport: open.panelWithinViewport,
panelToTriggerGap: open.panelToTriggerGap,
})
}
return stops
})()
return swept
}
it('keeps the actions row untouched by the note popover, which stays in the viewport', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-feedback-layout'))
const stops = await sweep()
for (const stop of stops) {
// The popover lives outside the row, so opening it must not change the
// row at all. This is the vacuity guard of the whole redesign: an inline
// editor would widen or reflow the row, pushing the delta off zero.
expect(stop.rowOverflowOpen - stop.rowOverflowClosed, `viewport ${String(stop.width)}`).toBe(0)
expect(stop.itemsOutsideColumnOpen - stop.itemsOutsideColumnClosed, `viewport ${String(stop.width)}`).toBe(0)
// The row is one 28px line; the editor never forces a reflow.
expect(stop.rowLines, `viewport ${String(stop.width)}`).toBe(1)
// The panel escapes the column's overflow clip by living outside it.
expect(stop.panelOutsideColumn, `viewport ${String(stop.width)}`).toBe(true)
// The placement clamps the panel inside the viewport at every width.
expect(stop.panelWithinViewport, `viewport ${String(stop.width)}`).toBe(true)
// The panel stays anchored to its trigger rather than drifting off.
expect(stop.panelToTriggerGap, `viewport ${String(stop.width)}`).toBeLessThanOrEqual(4)
}
expect(tripwire.pageErrors).toEqual([])
}, 180_000)
it('matches the committed geometry golden', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-feedback-layout-golden'))
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(await sweep()), MODE)
}, 180_000)
it('kept the console clean', () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
})
@@ -0,0 +1,10 @@
# Assistant actions row with the feedback note popover open
| viewport | row overflow delta | row lines | items-outside delta | panel outside the column | panel within the viewport | panel-to-trigger gap |
| --- | --- | --- | --- | --- | --- | --- |
| 1680px | 0px | 1 | 0 | true | true | 0px |
| 1280px | 0px | 1 | 0 | true | true | 0px |
| 1024px | 0px | 1 | 0 | true | true | 0px |
| 900px | 0px | 1 | 0 | true | true | 0px |
| 700px | 0px | 1 | 0 | true | true | 0px |
| 600px | 0px | 1 | 0 | true | true | 0px |
+1
View File
@@ -60,6 +60,7 @@
"tests/web-search-round.e2e.ts",
"tests/message-actions.e2e.ts",
"tests/message-feedback.e2e.ts",
"tests/message-feedback-layout.e2e.ts",
"tests/markdown-images.e2e.ts",
"tests/math-rendering.e2e.ts",
"tests/markdown-cjk-strong.e2e.ts",
+2 -2
View File
@@ -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/config-catalog.md
config-catalog.md: 2736fdccb8a270f4d1a362c0e4b9767c48cc8b6b
config-catalog.zh.md: 7a4a16214295091a5ca27e10fcfad368653bcb61
config-catalog.md: e5eaf7716327ba9e020bbeaaf524b442524559bd
config-catalog.zh.md: d5a7a6bc8fd4d50fef09471c92c7fa0cdf7cb95f
+2 -2
View File
@@ -2114,7 +2114,7 @@ export interface Config {
export type ClaudeCodePermissionMode = typeof CLAUDE_CODE_PERMISSION_MODES[number]
```
Source: [`packages/subagent/subagent-claude-code/src/index.ts:37`](../packages/subagent/subagent-claude-code/src/index.ts)
Source: [`packages/subagent/subagent-claude-code/src/index.ts:38`](../packages/subagent/subagent-claude-code/src/index.ts)
<a id="deepseek-aidsh-subagent-codex"></a>
@@ -2145,7 +2145,7 @@ export type CodexPermissionMode =
| 'dangerously-bypass-approvals-and-sandbox'
```
Source: [`packages/subagent/subagent-codex/src/index.ts:35`](../packages/subagent/subagent-codex/src/index.ts)
Source: [`packages/subagent/subagent-codex/src/index.ts:36`](../packages/subagent/subagent-codex/src/index.ts)
<a id="deepseek-aidsh-subagent-dsh-sdk"></a>
+2 -2
View File
@@ -2116,7 +2116,7 @@ export interface Config {
export type ClaudeCodePermissionMode = typeof CLAUDE_CODE_PERMISSION_MODES[number]
```
来源:[`packages/subagent/subagent-claude-code/src/index.ts:37`](../packages/subagent/subagent-claude-code/src/index.ts)
来源:[`packages/subagent/subagent-claude-code/src/index.ts:38`](../packages/subagent/subagent-claude-code/src/index.ts)
<a id="deepseek-aidsh-subagent-codex"></a>
@@ -2147,7 +2147,7 @@ export type CodexPermissionMode =
| 'dangerously-bypass-approvals-and-sandbox'
```
来源:[`packages/subagent/subagent-codex/src/index.ts:35`](../packages/subagent/subagent-codex/src/index.ts)
来源:[`packages/subagent/subagent-codex/src/index.ts:36`](../packages/subagent/subagent-codex/src/index.ts)
<a id="deepseek-aidsh-subagent-dsh-sdk"></a>
@@ -11,7 +11,28 @@ import { SessionId } from '@deepseek-ai/dsh-session'
export const name = 'subagent-result-diagnostic'
export const inject = ['subagents']
const DIAGNOSTIC = 'Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt'
const RESULTS = [
{
id: '00000000-0000-4000-8000-0000000000d1',
diagnostic: 'Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)',
output: [{ type: 'text' as const, text: 'partial assistant text' }],
},
{
id: '00000000-0000-4000-8000-0000000000d2',
diagnostic: 'Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)',
output: [],
},
{
id: '00000000-0000-4000-8000-0000000000d3',
diagnostic: 'Product subagent failure (product: Codex; stage: turn; category: httpConnectionFailed; HTTP status: 503)',
output: [{ type: 'text' as const, text: 'partial assistant text' }],
},
{
id: '00000000-0000-4000-8000-0000000000d4',
diagnostic: 'Product subagent failure (product: Codex; stage: turn; category: httpConnectionFailed; HTTP status: 503)',
output: [],
},
] as const
class DiagnosticProvider implements SubagentProvider {
readonly name = 'snapshot-diagnostic'
@@ -24,19 +45,16 @@ class DiagnosticProvider implements SubagentProvider {
throw new Error('snapshot diagnostic provider start aborted')
}
const index = this.starts++
if (index > 1) {
throw new Error('snapshot diagnostic provider expected exactly two starts')
const fixture = RESULTS[index]
if (fixture === undefined) {
throw new Error('snapshot diagnostic provider expected exactly four starts')
}
return {
id: SessionId(index === 0
? '00000000-0000-4000-8000-0000000000d1'
: '00000000-0000-4000-8000-0000000000d2'),
id: SessionId(fixture.id),
localAgent: undefined,
result: Promise.resolve({
output: index === 0
? [{ type: 'text' as const, text: 'partial assistant text' }]
: [],
diagnostic: DIAGNOSTIC,
output: [...fixture.output],
diagnostic: fixture.diagnostic,
stopReason: 'error' as const,
}),
dispose: async () => {},
@@ -2,6 +2,6 @@
"steps": [
{ "op": "initialize" },
{ "op": "newSession" },
{ "op": "prompt", "text": "Use subagent_codex in the foreground exactly once; its result will fail with a diagnostic and partial output. Then use subagent_codex in the background exactly once and collect subagent-1 with job_output using wait true. After observing both failures, reply with exactly PARENT_OBSERVED_DIAGNOSTICS. Do not call any other tools." }
{ "op": "prompt", "text": "Observe four diagnostic failures with subagent_codex. First call it in the foreground for the Claude Code diagnostic, then in the background for the same Claude Code diagnostic and collect subagent-1 with job_output using wait true. Next call it in the foreground for the Codex diagnostic, then in the background for the same Codex diagnostic and collect subagent-2 with job_output using wait true. After all four failures, reply with exactly PARENT_OBSERVED_DIAGNOSTICS. Do not call any other tools." }
]
}
@@ -3,8 +3,8 @@
"kind": "chunks",
"chunks": [
{ "type": "block-start", "index": 0, "blockType": "tool-call" },
{ "type": "tool-call-delta", "index": 0, "id": "call_diagnostic_foreground", "name": "subagent_codex", "argumentsDelta": "{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}" },
{ "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_diagnostic_foreground", "name": "subagent_codex", "arguments": "{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}" } },
{ "type": "tool-call-delta", "index": 0, "id": "call_claude_foreground", "name": "subagent_codex", "argumentsDelta": "{\"description\":\"Observe Claude foreground diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":false}" },
{ "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_claude_foreground", "name": "subagent_codex", "arguments": "{\"description\":\"Observe Claude foreground diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":false}" } },
{ "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } },
{ "type": "finish", "reason": { "kind": "tool-calls" } }
]
@@ -13,8 +13,8 @@
"kind": "chunks",
"chunks": [
{ "type": "block-start", "index": 0, "blockType": "tool-call" },
{ "type": "tool-call-delta", "index": 0, "id": "call_diagnostic_background", "name": "subagent_codex", "argumentsDelta": "{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}" },
{ "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_diagnostic_background", "name": "subagent_codex", "arguments": "{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}" } },
{ "type": "tool-call-delta", "index": 0, "id": "call_claude_background", "name": "subagent_codex", "argumentsDelta": "{\"description\":\"Observe Claude background diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":true}" },
{ "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_claude_background", "name": "subagent_codex", "arguments": "{\"description\":\"Observe Claude background diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":true}" } },
{ "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } },
{ "type": "finish", "reason": { "kind": "tool-calls" } }
]
@@ -23,8 +23,38 @@
"kind": "chunks",
"chunks": [
{ "type": "block-start", "index": 0, "blockType": "tool-call" },
{ "type": "tool-call-delta", "index": 0, "id": "call_diagnostic_output", "name": "job_output", "argumentsDelta": "{\"job_id\":\"subagent-1\",\"wait\":true}" },
{ "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_diagnostic_output", "name": "job_output", "arguments": "{\"job_id\":\"subagent-1\",\"wait\":true}" } },
{ "type": "tool-call-delta", "index": 0, "id": "call_claude_output", "name": "job_output", "argumentsDelta": "{\"job_id\":\"subagent-1\",\"wait\":true}" },
{ "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_claude_output", "name": "job_output", "arguments": "{\"job_id\":\"subagent-1\",\"wait\":true}" } },
{ "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } },
{ "type": "finish", "reason": { "kind": "tool-calls" } }
]
},
{
"kind": "chunks",
"chunks": [
{ "type": "block-start", "index": 0, "blockType": "tool-call" },
{ "type": "tool-call-delta", "index": 0, "id": "call_codex_foreground", "name": "subagent_codex", "argumentsDelta": "{\"description\":\"Observe Codex foreground diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":false}" },
{ "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_codex_foreground", "name": "subagent_codex", "arguments": "{\"description\":\"Observe Codex foreground diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":false}" } },
{ "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } },
{ "type": "finish", "reason": { "kind": "tool-calls" } }
]
},
{
"kind": "chunks",
"chunks": [
{ "type": "block-start", "index": 0, "blockType": "tool-call" },
{ "type": "tool-call-delta", "index": 0, "id": "call_codex_background", "name": "subagent_codex", "argumentsDelta": "{\"description\":\"Observe Codex background diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":true}" },
{ "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_codex_background", "name": "subagent_codex", "arguments": "{\"description\":\"Observe Codex background diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":true}" } },
{ "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } },
{ "type": "finish", "reason": { "kind": "tool-calls" } }
]
},
{
"kind": "chunks",
"chunks": [
{ "type": "block-start", "index": 0, "blockType": "tool-call" },
{ "type": "tool-call-delta", "index": 0, "id": "call_codex_output", "name": "job_output", "argumentsDelta": "{\"job_id\":\"subagent-2\",\"wait\":true}" },
{ "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_codex_output", "name": "job_output", "arguments": "{\"job_id\":\"subagent-2\",\"wait\":true}" } },
{ "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } },
{ "type": "finish", "reason": { "kind": "tool-calls" } }
]
@@ -1,51 +1,84 @@
{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{cwd}}","delegationDepth":0}
{"type":"agent/inbox/spliced","seq":0,"time":1785498761270,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use subagent_codex in the foreground exactly once; its result will fail with a diagnostic and partial output. Then use subagent_codex in the background exactly once and collect subagent-1 with job_output using wait true. After observing both failures, reply with exactly PARENT_OBSERVED_DIAGNOSTICS. Do not call any other tools."}],"source":{"kind":"user"},"role":"user","id":"b8004c02-9892-40a7-b7a4-28f04879082c"}]}}
{"type":"agent/inbox/spliced","seq":0,"time":1785498761270,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Observe four diagnostic failures with subagent_codex. First call it in the foreground for the Claude Code diagnostic, then in the background for the same Claude Code diagnostic and collect subagent-1 with job_output using wait true. Next call it in the foreground for the Codex diagnostic, then in the background for the same Codex diagnostic and collect subagent-2 with job_output using wait true. After all four failures, reply with exactly PARENT_OBSERVED_DIAGNOSTICS. Do not call any other tools."}],"source":{"kind":"user"},"role":"user","id":"eb9f20a0-9eac-480c-9904-71a1ffbb742a"}]}}
{"type":"turn/start","seq":1,"time":1785821359466,"data":{"turn":1}}
{"type":"agent/inbox/spliced","seq":2,"time":1785821359466,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","seq":3,"time":1783600629542,"data":{"turn":1,"step":1}}
{"type":"user/message","seq":4,"time":1785498761313,"data":{"content":[{"type":"text","text":"Use subagent_codex in the foreground exactly once; its result will fail with a diagnostic and partial output. Then use subagent_codex in the background exactly once and collect subagent-1 with job_output using wait true. After observing both failures, reply with exactly PARENT_OBSERVED_DIAGNOSTICS. Do not call any other tools."}],"source":{"kind":"user"},"role":"user","id":"b8004c02-9892-40a7-b7a4-28f04879082c"},"surfaceOp":"append"}
{"type":"user/message","seq":4,"time":1785498761313,"data":{"content":[{"type":"text","text":"Observe four diagnostic failures with subagent_codex. First call it in the foreground for the Claude Code diagnostic, then in the background for the same Claude Code diagnostic and collect subagent-1 with job_output using wait true. Next call it in the foreground for the Codex diagnostic, then in the background for the same Codex diagnostic and collect subagent-2 with job_output using wait true. After all four failures, reply with exactly PARENT_OBSERVED_DIAGNOSTICS. Do not call any other tools."}],"source":{"kind":"user"},"role":"user","id":"eb9f20a0-9eac-480c-9904-71a1ffbb742a"},"surfaceOp":"append"}
{"type":"user/message","seq":5,"time":1785730415287,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"4b8d9730-0b7b-4e14-8a30-3d852f808f0e"},"surfaceOp":"append"}
{"type":"session/title","seq":6,"time":1785730415287,"data":{"title":"Use subagent_codex in the foreground","messageSeqs":[4],"source":{"kind":"fallback"}}}
{"type":"session/title","seq":6,"time":1785730415287,"data":{"title":"Observe four diagnostic failures with","messageSeqs":[4],"source":{"kind":"fallback"}}}
{"type":"request/header","seq":7,"time":1785498761318,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","seq":8,"time":1785730415288,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}}
{"type":"assistant/chunk","seq":9,"time":1783600630822,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":10,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_diagnostic_foreground","name":"subagent_codex","argumentsDelta":"{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}"}}}
{"type":"assistant/chunk","seq":11,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_diagnostic_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}"}}}}
{"type":"assistant/chunk","seq":10,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_claude_foreground","name":"subagent_codex","argumentsDelta":"{\"description\":\"Observe Claude foreground diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":false}"}}}
{"type":"assistant/chunk","seq":11,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_claude_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude foreground diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":false}"}}}}
{"type":"assistant/chunk","seq":12,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","seq":13,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":14,"time":1786781990608,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_diagnostic_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"92e33995-2f02-4ad5-aec1-9df82cf4d583"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"}
{"type":"tool/call","seq":15,"time":1786781990608,"data":{"turn":1,"step":1,"callId":"call_diagnostic_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}"}}
{"type":"tool/result","seq":16,"time":1786781990613,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_diagnostic_foreground"},"content":[{"type":"tool-result","toolCallId":"call_diagnostic_foreground","content":[{"type":"text","text":"Error: subagent run failed\nDiagnostic: Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt\nPartial output before the run ended:\npartial assistant text"}],"isError":true}],"role":"user","id":"4e84e7b3-40c1-488e-b119-45e8bd7ce448"}},"sourceEventSeqs":[15],"surfaceOp":"append"}
{"type":"assistant/message","seq":14,"time":1786781990608,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_claude_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude foreground diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"3cc2d0b5-97a5-4685-af60-ed7f7db8f69a"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"}
{"type":"tool/call","seq":15,"time":1786781990608,"data":{"turn":1,"step":1,"callId":"call_claude_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude foreground diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":false}"}}
{"type":"tool/result","seq":16,"time":1786781990613,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_claude_foreground"},"content":[{"type":"tool-result","toolCallId":"call_claude_foreground","content":[{"type":"text","text":"Error: subagent run failed\nDiagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)\nPartial output before the run ended:\npartial assistant text"}],"isError":true}],"role":"user","id":"8743817e-158e-45cb-88d9-a695b2653eca"}},"sourceEventSeqs":[15],"surfaceOp":"append"}
{"type":"step/end","seq":17,"time":1786781990613,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":18,"time":1786781990618,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":19,"time":1783600630926,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":20,"time":1783600630926,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_diagnostic_background","name":"subagent_codex","argumentsDelta":"{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}"}}}
{"type":"assistant/chunk","seq":21,"time":1783600630926,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_diagnostic_background","name":"subagent_codex","arguments":"{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}"}}}}
{"type":"assistant/chunk","seq":20,"time":1783600630926,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_claude_background","name":"subagent_codex","argumentsDelta":"{\"description\":\"Observe Claude background diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":true}"}}}
{"type":"assistant/chunk","seq":21,"time":1783600630926,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_claude_background","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude background diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":true}"}}}}
{"type":"assistant/chunk","seq":22,"time":1783600630944,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","seq":23,"time":1783600630944,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":24,"time":1786781990622,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_diagnostic_background","name":"subagent_codex","arguments":"{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"2fb444e2-7a52-4963-988e-b1ecbc3744d5"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"}
{"type":"tool/call","seq":25,"time":1786781990623,"data":{"turn":1,"step":2,"callId":"call_diagnostic_background","name":"subagent_codex","arguments":"{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}"}}
{"type":"agent/inbox/spliced","seq":26,"time":1786781990627,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"background job subagent-1 (subagent: Observe background diagnostic) finished [status: failed, error; diagnostic: Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe background diagnostic [status: failed, error; diagnostic: Claude Code unattended decision (mode: dontA…"},"role":"user","id":"de606545-e637-4d9a-ba17-4c722a7331fd"}]}}
{"type":"tool/result","seq":27,"time":1786781990627,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_diagnostic_background"},"content":[{"type":"tool-result","toolCallId":"call_diagnostic_background","content":[{"type":"text","text":"started background subagent job subagent-1"}],"isError":false}],"role":"user","id":"3377f724-b4a7-4ce1-bed7-774f174917d6"}},"sourceEventSeqs":[25],"surfaceOp":"append"}
{"type":"assistant/message","seq":24,"time":1786781990622,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_claude_background","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude background diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"b504312a-1dc5-46ce-87a5-12a5817511b9"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"}
{"type":"tool/call","seq":25,"time":1786781990623,"data":{"turn":1,"step":2,"callId":"call_claude_background","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude background diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":true}"}}
{"type":"agent/inbox/spliced","seq":26,"time":1786781990627,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"background job subagent-1 (subagent: Observe Claude background diagnostic) finished [status: failed, error; diagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe Claude background diagnostic [status: failed, error; diagnostic: Product subagent failure (product: Cl…"},"role":"user","id":"0fdb9ddf-1657-4455-9941-e6a9daa8ae4a"}]}}
{"type":"tool/result","seq":27,"time":1786781990627,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_claude_background"},"content":[{"type":"tool-result","toolCallId":"call_claude_background","content":[{"type":"text","text":"started background subagent job subagent-1"}],"isError":false}],"role":"user","id":"fe60646b-0551-4703-aa03-c8cb5460d356"}},"sourceEventSeqs":[25],"surfaceOp":"append"}
{"type":"step/end","seq":28,"time":1786781990627,"data":{"turn":1,"step":2}}
{"type":"agent/inbox/spliced","seq":29,"time":1786781990627,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","seq":30,"time":1786781990632,"data":{"turn":1,"step":3}}
{"type":"user/message","seq":31,"time":1786781990632,"data":{"content":[{"type":"text","text":"background job subagent-1 (subagent: Observe background diagnostic) finished [status: failed, error; diagnostic: Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe background diagnostic [status: failed, error; diagnostic: Claude Code unattended decision (mode: dontA…"},"role":"user","id":"de606545-e637-4d9a-ba17-4c722a7331fd"},"surfaceOp":"append"}
{"type":"user/message","seq":31,"time":1786781990632,"data":{"content":[{"type":"text","text":"background job subagent-1 (subagent: Observe Claude background diagnostic) finished [status: failed, error; diagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe Claude background diagnostic [status: failed, error; diagnostic: Product subagent failure (product: Cl…"},"role":"user","id":"0fdb9ddf-1657-4455-9941-e6a9daa8ae4a"},"surfaceOp":"append"}
{"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":33,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_diagnostic_output","name":"job_output","argumentsDelta":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}}
{"type":"assistant/chunk","seq":34,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_diagnostic_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}}}
{"type":"assistant/chunk","seq":33,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_claude_output","name":"job_output","argumentsDelta":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}}
{"type":"assistant/chunk","seq":34,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_claude_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}}}
{"type":"assistant/chunk","seq":35,"time":1785498761338,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","seq":36,"time":1785730415297,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":37,"time":1785730415298,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_diagnostic_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"f43f988b-bc08-4811-8671-8edc0613f0d0"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"}
{"type":"tool/call","seq":38,"time":1786781990636,"data":{"turn":1,"step":3,"callId":"call_diagnostic_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}
{"type":"tool/result","seq":39,"time":1786781990640,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_diagnostic_output"},"content":[{"type":"tool-result","toolCallId":"call_diagnostic_output","content":[{"type":"text","text":"(no new output)\n[status: failed, error; diagnostic: Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt]"}],"isError":false}],"role":"user","id":"6785120f-ae46-48d0-9f3f-d6cd1e6fc5d7"}},"sourceEventSeqs":[38],"surfaceOp":"append"}
{"type":"assistant/message","seq":37,"time":1785730415298,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_claude_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"c48a520a-74ed-42ee-9d93-ee59899975b0"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"}
{"type":"tool/call","seq":38,"time":1786781990636,"data":{"turn":1,"step":3,"callId":"call_claude_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}
{"type":"tool/result","seq":39,"time":1786781990640,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_claude_output"},"content":[{"type":"tool-result","toolCallId":"call_claude_output","content":[{"type":"text","text":"(no new output)\n[status: failed, error; diagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)]"}],"isError":false}],"role":"user","id":"45bc0705-7243-4173-a119-4c0655af8dc1"}},"sourceEventSeqs":[38],"surfaceOp":"append"}
{"type":"step/end","seq":40,"time":1786781990640,"data":{"turn":1,"step":3}}
{"type":"step/start","seq":41,"time":1786781990645,"data":{"turn":1,"step":4}}
{"type":"assistant/chunk","seq":42,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":43,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":0,"text":"PARENT_OBSERVED_DIAGNOSTICS"}}}
{"type":"assistant/chunk","seq":44,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_OBSERVED_DIAGNOSTICS"}}}}
{"type":"assistant/chunk","seq":45,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}}
{"type":"assistant/chunk","seq":46,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":47,"time":1786781990649,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_OBSERVED_DIAGNOSTICS"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"49b868e8-2608-47e0-aaf8-b308ffe8194d"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[42,43,44,45,46],"surfaceOp":"append"}
{"type":"step/end","seq":48,"time":1786781990650,"data":{"turn":1,"step":4}}
{"type":"turn/end","seq":49,"time":1786781990650,"data":{"turn":1,"reason":{"kind":"completed"}}}
{"type":"assistant/chunk","seq":42,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":43,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"call_codex_foreground","name":"subagent_codex","argumentsDelta":"{\"description\":\"Observe Codex foreground diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":false}"}}}
{"type":"assistant/chunk","seq":44,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_codex_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Codex foreground diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":false}"}}}}
{"type":"assistant/chunk","seq":45,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","seq":46,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":47,"time":1786781990649,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_codex_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Codex foreground diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"89ab3728-fc3f-4825-97e5-383d46568d8c"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[42,43,44,45,46],"surfaceOp":"append"}
{"type":"tool/call","seq":48,"time":1786994591759,"data":{"turn":1,"step":4,"callId":"call_codex_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Codex foreground diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":false}"}}
{"type":"tool/result","seq":49,"time":1786994591762,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"call_codex_foreground"},"content":[{"type":"tool-result","toolCallId":"call_codex_foreground","content":[{"type":"text","text":"Error: subagent run failed\nDiagnostic: Product subagent failure (product: Codex; stage: turn; category: httpConnectionFailed; HTTP status: 503)\nPartial output before the run ended:\npartial assistant text"}],"isError":true}],"role":"user","id":"0a8fd87c-eacb-457b-a5ad-29dd88f599aa"}},"sourceEventSeqs":[48],"surfaceOp":"append"}
{"type":"step/end","seq":50,"time":1786994591762,"data":{"turn":1,"step":4}}
{"type":"step/start","seq":51,"time":1786994591767,"data":{"turn":1,"step":5}}
{"type":"assistant/chunk","seq":52,"time":1786994591771,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":53,"time":1786994591771,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"call_codex_background","name":"subagent_codex","argumentsDelta":"{\"description\":\"Observe Codex background diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":true}"}}}
{"type":"assistant/chunk","seq":54,"time":1786994591771,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_codex_background","name":"subagent_codex","arguments":"{\"description\":\"Observe Codex background diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":true}"}}}}
{"type":"assistant/chunk","seq":55,"time":1786994591771,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","seq":56,"time":1786994591771,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":57,"time":1786994591771,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_codex_background","name":"subagent_codex","arguments":"{\"description\":\"Observe Codex background diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"996da601-acb8-49c9-8dd7-e60a88a8f1a2"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[52,53,54,55,56],"surfaceOp":"append"}
{"type":"tool/call","seq":58,"time":1786994591772,"data":{"turn":1,"step":5,"callId":"call_codex_background","name":"subagent_codex","arguments":"{\"description\":\"Observe Codex background diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":true}"}}
{"type":"agent/inbox/spliced","seq":59,"time":1786994591775,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"background job subagent-2 (subagent: Observe Codex background diagnostic) finished [status: failed, error; diagnostic: Product subagent failure (product: Codex; stage: turn; category: httpConnectionFailed; HTTP status: 503)]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe Codex background diagnostic [status: failed, error; diagnostic: Product subagent failure (product: Cod…"},"role":"user","id":"5f1d4517-50d4-48ef-8acc-8f9361ecb185"}]}}
{"type":"tool/result","seq":60,"time":1786994591775,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"call_codex_background"},"content":[{"type":"tool-result","toolCallId":"call_codex_background","content":[{"type":"text","text":"started background subagent job subagent-2"}],"isError":false}],"role":"user","id":"a8ba8362-275b-4bca-8b88-d1ef84d325a3"}},"sourceEventSeqs":[58],"surfaceOp":"append"}
{"type":"step/end","seq":61,"time":1786994591776,"data":{"turn":1,"step":5}}
{"type":"agent/inbox/spliced","seq":62,"time":1786994591776,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","seq":63,"time":1786994591781,"data":{"turn":1,"step":6}}
{"type":"user/message","seq":64,"time":1786994591781,"data":{"content":[{"type":"text","text":"background job subagent-2 (subagent: Observe Codex background diagnostic) finished [status: failed, error; diagnostic: Product subagent failure (product: Codex; stage: turn; category: httpConnectionFailed; HTTP status: 503)]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe Codex background diagnostic [status: failed, error; diagnostic: Product subagent failure (product: Cod…"},"role":"user","id":"5f1d4517-50d4-48ef-8acc-8f9361ecb185"},"surfaceOp":"append"}
{"type":"assistant/chunk","seq":65,"time":1786994591788,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":66,"time":1786994591788,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":0,"id":"call_codex_output","name":"job_output","argumentsDelta":"{\"job_id\":\"subagent-2\",\"wait\":true}"}}}
{"type":"assistant/chunk","seq":67,"time":1786994591788,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_codex_output","name":"job_output","arguments":"{\"job_id\":\"subagent-2\",\"wait\":true}"}}}}
{"type":"assistant/chunk","seq":68,"time":1786994591789,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","seq":69,"time":1786994591789,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":70,"time":1786994591789,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_codex_output","name":"job_output","arguments":"{\"job_id\":\"subagent-2\",\"wait\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"cfc1726c-5d9e-486a-aa0f-057219e16dfd"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[65,66,67,68,69],"surfaceOp":"append"}
{"type":"tool/call","seq":71,"time":1786994591789,"data":{"turn":1,"step":6,"callId":"call_codex_output","name":"job_output","arguments":"{\"job_id\":\"subagent-2\",\"wait\":true}"}}
{"type":"tool/result","seq":72,"time":1786994591797,"data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"call_codex_output"},"content":[{"type":"tool-result","toolCallId":"call_codex_output","content":[{"type":"text","text":"(no new output)\n[status: failed, error; diagnostic: Product subagent failure (product: Codex; stage: turn; category: httpConnectionFailed; HTTP status: 503)]"}],"isError":false}],"role":"user","id":"9671e5ee-f443-4548-8fd2-b0b76f00b629"}},"sourceEventSeqs":[71],"surfaceOp":"append"}
{"type":"step/end","seq":73,"time":1786994591797,"data":{"turn":1,"step":6}}
{"type":"step/start","seq":74,"time":1786994591802,"data":{"turn":1,"step":7}}
{"type":"assistant/chunk","seq":75,"time":1786994591806,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":76,"time":1786994591806,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":0,"text":"PARENT_OBSERVED_DIAGNOSTICS"}}}
{"type":"assistant/chunk","seq":77,"time":1786994591806,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_OBSERVED_DIAGNOSTICS"}}}}
{"type":"assistant/chunk","seq":78,"time":1786994591806,"data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}}
{"type":"assistant/chunk","seq":79,"time":1786994591806,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":80,"time":1786994591806,"data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_OBSERVED_DIAGNOSTICS"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"49b868e8-2608-47e0-aaf8-b308ffe8194d"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[75,76,77,78,79],"surfaceOp":"append"}
{"type":"step/end","seq":81,"time":1786994591806,"data":{"turn":1,"step":7}}
{"type":"turn/end","seq":82,"time":1786994591807,"data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -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-message-feedback/README.md
README.md: 461e87589567eb95b075839d5893cc551e67a035
README.zh.md: 31f722021ff65f476a6ba1ab0211fd4e091671d2
README.md: d3bb4e28b95da2fde0d26b7ef83ebca377b4939f
README.zh.md: d823bb9de2b7d39d0bc5d00164b8ce8dffec4a34
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Per-message feedback plugin, browser half: a Like/Dislike pair plus an optional note, contributed as the `feedback` entry (order 10) of the `conversation.chat.assistant-actions` strip. The strip is declared by `ui-conversation` and rendered inside the finalized assistant message's IconActions row, between copy and branch, so the controls inherit that row's chrome and hover behavior. Only finalized messages reach the slot — an interruption-frozen partial carries no `messageId` and therefore no feedback controls. The strip renders once per turn, on the closing assistant message that owns the turn's IconActions row: earlier steps of a multi-step turn produce tool rows rather than a rateable body, so they present no controls even though the Host would accept them as targets.
Per-message feedback plugin, browser half: a Like/Dislike pair plus an optional note, contributed as the `feedback` entry (order 10) of the `conversation.chat.assistant-actions` strip. The strip is declared by `ui-conversation` and rendered inside the finalized assistant message's IconActions row, between copy and branch, so the controls inherit that row's chrome and hover behavior. The note editor itself does not sit in that row: it is a `role="dialog"` popover portaled to `document.body` and anchored under its trigger, so the row keeps its single line whether the editor is open or closed and the panel is not clipped by the conversation column. A rating or list-load failure shows inline in the row; a note-save failure shows inside the popover, which stays open so the draft can be corrected. Only finalized messages reach the slot — an interruption-frozen partial carries no `messageId` and therefore no feedback controls. The strip renders once per turn, on the closing assistant message that owns the turn's IconActions row: earlier steps of a multi-step turn produce tool rows rather than a rateable body, so they present no controls even though the Host would accept them as targets.
One `MessageFeedbackController` per Session backs every message control in that Session, so a single `messageFeedback.list` read seeds the whole transcript. The read is deferred to the first hover or focus rather than fired on mount, because the controls mount once per settled message in the visible history.
@@ -2,7 +2,7 @@
[English](README.md) | 中文
单条消息反馈插件的浏览器侧:一对 Like/Dislike 按钮加一个可选备注,作为 `conversation.chat.assistant-actions` 条带的 `feedback` 条目(order 10)贡献。该条带由 `ui-conversation` 声明,渲染在已定稿助手消息的 IconActions 行内、复制与分支之间,因此控件沿用该行的样式与 hover 行为。只有已定稿的消息能到达这个 slot——被中断冻结的部分输出不带 `messageId`,因此也没有反馈控件。该操作栏每个 Turn 渲染一次,位于持有该 Turn IconActions 行的收尾助手消息上:多步骤 Turn 中较早的步骤产出的是工具行而非可评分正文,因此即使 Host 会接受它们作为目标,界面上也不出现控件。
单条消息反馈插件的浏览器侧:一对 Like/Dislike 按钮加一个可选备注,作为 `conversation.chat.assistant-actions` 条带的 `feedback` 条目(order 10)贡献。该条带由 `ui-conversation` 声明,渲染在已定稿助手消息的 IconActions 行内、复制与分支之间,因此控件沿用该行的样式与 hover 行为。备注编辑器本身不在这一行里:它是一个 `role="dialog"` 的浮层,portal 到 `document.body` 并锚定在其触发按钮下方,因此无论编辑器是否打开该行都保持单行,面板也不会被会话列裁掉。评分或列表加载失败在行内展示;备注保存失败在浮层内展示,且面板保持打开以便修正草稿。只有已定稿的消息能到达这个 slot——被中断冻结的部分输出不带 `messageId`,因此也没有反馈控件。该操作栏每个 Turn 渲染一次,位于持有该 Turn IconActions 行的收尾助手消息上:多步骤 Turn 中较早的步骤产出的是工具行而非可评分正文,因此即使 Host 会接受它们作为目标,界面上也不出现控件。
每个 Session 一个 `MessageFeedbackController`,支撑该 Session 内所有消息的控件,因此一次 `messageFeedback.list` 读取即可填充整段对话。该读取延迟到首次 hover 或 focus 才发起,而不是在挂载时触发,因为可见历史中每条已结束的消息都会挂载一次控件。
@@ -71,6 +71,7 @@
"@deepseek-ai/cordis": "workspace:^",
"@testing-library/react": "^16.1.0",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
@@ -1,6 +1,9 @@
/* Per-message feedback controls. The rating buttons mirror the shared message
IconActions chrome so the strip reads as one row; the note editor is an
inline expansion anchored to the same row. */
IconActions chrome so the strip reads as one row. The note editor is a
popover portaled to document.body and fixed from the note trigger's rect,
so it neither competes with the row for inline width nor gets cropped by the
conversation column's overflow clip. Surface recipe follows the Menu card:
r12, inverted hairline border, shadow-lv3. */
.action {
display: inline-flex;
@@ -47,29 +50,58 @@
cursor: pointer;
}
.noteOpen:hover {
.noteOpen:hover,
.noteOpen[aria-expanded='true'] {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
.noteEditor {
display: inline-flex;
align-items: flex-start;
gap: 6px;
/* Portal surface: fixed in the viewport, left/top supplied inline from the
trigger rect. Portaled panels must layer above modal overlays (z 1000). */
.notePanel {
position: fixed;
z-index: 1100;
box-sizing: border-box;
width: 320px;
max-width: min(360px, calc(100vw - 24px));
/* The width bound's counterpart. `resize: vertical` on the textarea lets the
panel be dragged taller, and a panel taller than the viewport would push
the placement clamp's upper bound below its own margin, so `top` would go
negative and cut off the panel's head. Both bounds keep the 12px margin
the clamp uses. */
max-height: calc(100vh - 24px);
overflow-y: auto;
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
border: 1px solid var(--dsw-alias-border-inverted);
border-radius: 12px;
background: var(--dsw-specific-menu);
box-shadow: var(--dsw-shadow-lv3);
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
.noteInput {
width: 260px;
width: 100%;
box-sizing: border-box;
padding: 6px 8px;
border: 1px solid var(--dsw-alias-border-secondary);
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
background: var(--dsw-alias-bg-primary);
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
font: inherit;
font-size: 13px;
resize: vertical;
}
.noteActions {
display: flex;
justify-content: flex-end;
gap: 6px;
}
.noteSave,
.noteCancel {
height: 28px;
@@ -81,8 +113,12 @@
}
.noteSave {
background: var(--dsw-alias-interactive-bg-primary);
color: var(--dsw-alias-label-inverse);
background: var(--dsw-alias-button-primary-fill);
color: var(--dsw-alias-label-primary-foreground);
}
.noteSave:hover:not(:disabled) {
background: var(--dsw-alias-button-primary-hover);
}
.noteSave:disabled {
@@ -104,5 +140,5 @@
padding-left: 4px;
color: var(--dsw-alias-label-tertiary);
font-size: 13px;
line-height: 28px;
line-height: 20px;
}
@@ -1,23 +1,47 @@
/**
* Per-message feedback controls: a Like/Dislike pair plus an optional note.
* Rendered inside the assistant message's IconActions row, so the buttons
* reuse that row's chrome and sit between copy and branch.
* The buttons render inside the assistant message's IconActions row, so they
* reuse that row's chrome and sit between copy and branch. The note editor is
* a popover (portaled to `document.body`) anchored to the note trigger, not an
* inline expansion: a 260px textarea plus buttons cannot fit the row at any
* viewport, and an inline element pushed the branch action and clock out of the
* conversation column. Portaling out of the column also escapes its `overflow`
* clip, so the panel cannot be cropped or detached from the message it annotates.
* @module @deepseek-ai/dsh-client-ui-message-feedback/client/MessageFeedbackActions
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import {
IconDislikeOutline16, IconLikeOutline16, Tooltip,
useCallback, useEffect, useRef, useState,
type CSSProperties,
} from 'react'
import { createPortal } from 'react-dom'
import {
IconDislikeOutline16, IconLikeOutline16, Tooltip, useAnchoredPosition,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { MessageFeedbackRating } from '@deepseek-ai/dsh-message-feedback/types'
import type { MessageFeedbackActionProps } from './slots.ts'
import css from './MessageFeedbackActions.module.css'
/** Safe distance kept between the panel and the viewport edges (the Menu portal margin). */
const PANEL_MARGIN = 12
/** Distance between the trigger's bottom edge and the panel's top. */
const PANEL_GAP = 4
/**
* Unplaced portal panel: hidden but laid out so `offsetWidth` is real for the
* clamp. The explicit insets match `Menu`'s measure style a `position: fixed`
* element with auto insets otherwise sits at its static position, a different
* origin than the one the first placement measures from.
*/
const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
/**
* One message's feedback controls.
* @param props - the owner's message identity, the injected verbs, and the
* shared feedback hook.
* @returns the rating buttons, plus the note editor while it is open.
* @returns the rating buttons and the note trigger, with the note editor
* portal-open beneath the trigger while it is open.
*/
export function MessageFeedbackActions({ messageId, ensure, rate, toggle, clearNote, useFeedback, t }: MessageFeedbackActionProps) {
const item = useFeedback(view => view.items.get(messageId))
@@ -26,7 +50,15 @@ export function MessageFeedbackActions({ messageId, ensure, rate, toggle, clearN
const [noteOpen, setNoteOpen] = useState(false)
const [draft, setDraft] = useState('')
const [pending, setPending] = useState(false)
const [failure, setFailure] = useState<string | null>(null)
// A rating or load failure surfaces beside the rating buttons, always legible
// whether or not the note popover is open.
const [rowFailure, setRowFailure] = useState<string | null>(null)
// A note save failure surfaces inside the note popover, where the human is
// looking; it stays open so the draft survives to be corrected.
const [noteFailure, setNoteFailure] = useState<string | null>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
// The controls mount for every settled message in the transcript, so the
// Session's feedback is read once on first hover/focus rather than on mount.
const seeded = useRef(false)
@@ -39,47 +71,158 @@ export function MessageFeedbackActions({ messageId, ensure, rate, toggle, clearN
const alive = useRef(true)
useEffect(() => () => { alive.current = false }, [])
const settle = useCallback((result: { ok: boolean; error?: { code: string } }) => {
/** Bumped whenever an editing session ends, so a late save can tell it is stale. */
const noteGeneration = useRef(0)
/** Current panel open-state, readable from a stale closure via a ref. */
const noteOpenRef = useRef(false)
useEffect(() => { noteOpenRef.current = noteOpen }, [noteOpen])
const errorCopy = useCallback((result: { ok: boolean; error?: { code: string } }) => {
return result.error?.code === 'version-conflict' ? t('error.conflict') : t('error.generic')
}, [t])
const settleRating = useCallback((result: { ok: boolean; error?: { code: string } }) => {
if (!alive.current) return
setPending(false)
if (result.ok) {
setFailure(null)
return
}
setFailure(result.error?.code === 'version-conflict' ? t('error.conflict') : t('error.generic'))
}, [t])
setRowFailure(result.ok ? null : errorCopy(result))
}, [errorCopy])
const closeNote = useCallback(() => {
// Ends the editing session, so any save still in flight becomes stale.
noteGeneration.current += 1
setNoteOpen(false)
}, [])
const onRate = useCallback((next: MessageFeedbackRating) => {
setPending(true)
setFailure(null)
setRowFailure(null)
// The controller decides retract-vs-replace from the committed item, so a
// click that lands before the first list read still toggles the stored
// value instead of this render's empty view.
setNoteOpen(false)
void toggle(messageId, next).then(settle)
}, [messageId, settle, toggle])
closeNote()
void toggle(messageId, next).then(settleRating)
}, [closeNote, messageId, settleRating, toggle])
// The rating is a parameter because only the note editor's render site can
// prove one is recorded; that removes an unreachable undefined guard here.
const onSaveNote = useCallback((current: MessageFeedbackRating) => {
const trimmed = draft.trim()
setPending(true)
setFailure(null)
setNoteFailure(null)
// A save belongs to the editing session that started it. Closing and
// reopening the panel begins a new one, and a late reply from the old
// session must not act on it: a stale success would shut the panel the
// human just opened, and a stale failure would describe a draft this
// session never sent.
const generation = noteGeneration.current
// What a session reopened before this save commits would be seeded with.
const staleSeed = item?.note ?? ''
// An emptied editor removes the note explicitly; `rate` alone preserves a
// stored note, so it cannot express deletion.
const settled = trimmed.length === 0
? clearNote(messageId)
: rate(messageId, current, trimmed)
void settled.then((result) => {
settle(result)
if (result.ok && alive.current) setNoteOpen(false)
if (!alive.current) return
// `pending` tracks the request in flight, not the editing session, so it
// is released either way; all three of like, dislike and Save read
// `disabled={pending}`, and holding it would lock the row until remount.
// Releasing it unconditionally is safe because those three are the only
// mutation entries and each is gated by it, so at most one request is ever
// in flight. A future entry that bypasses the gate would have to bind
// `pending` to the generation instead of clearing it here.
setPending(false)
if (result.ok) {
// Only the session that is still open may act on a success: closing it
// already discarded the draft, and reopening seeded a new one.
if (generation === noteGeneration.current) {
setNoteFailure(null)
setNoteOpen(false)
return
}
// A newer session is open, seeded from the note as it read before this
// save committed. Resync it so the editor shows what is stored and the
// next save cannot overwrite the text that just landed. An edited draft
// is the human's, so it is left alone.
setDraft(draftNow => (draftNow === staleSeed ? trimmed : draftNow))
return
}
// A failure from the session still on screen belongs in its panel. One
// from an abandoned session is reported only when no new session has
// taken over: the row then carries it, so a save that failed after the
// human walked away is not silently dropped. Writing it into a reopened
// panel instead would label the new draft with the old attempt's error.
// `noteOpenRef` — not the `noteOpen` this closure was created from — is
// read here, because a close+reopen between the save and resolution
// leaves this closure with the panel state from when the save started.
if (generation === noteGeneration.current || !noteOpenRef.current) {
setNoteFailure(errorCopy(result))
}
})
}, [clearNote, draft, messageId, rate, settle])
}, [clearNote, draft, errorCopy, item?.note, messageId, noteOpenRef, rate])
const openNote = useCallback(() => {
// The trigger toggles: while closed it opens the popover (seeding the draft
// with the recorded note), while open it closes it. Toggling closed via the
// trigger also fires the outside/within logic correctly because the trigger
// is inside the panel's "inside" region.
const toggleNote = useCallback(() => {
if (noteOpen) {
closeNote()
return
}
setDraft(item?.note ?? '')
// A note-save failure belongs to the editing session that produced it. The
// panel stays open on failure so the draft can be corrected, but once it is
// closed and reopened the draft is reseeded from the stored note, so a
// carried-over error would describe an attempt the new draft never made.
// A failure that arrives after the panel closed is reported in the row, and
// clearing it here is what retires that notice when a new session starts.
setNoteFailure(null)
setNoteOpen(true)
}, [item?.note])
}, [noteOpen, closeNote, item?.note])
// Place the portaled panel from the trigger rect before paint and keep it
// with the trigger on scroll/resize, the same anchoring `Menu` uses for its
// portal mode.
const pos = useAnchoredPosition({
open: noteOpen,
anchorRef: triggerRef,
panelRef,
gap: PANEL_GAP,
margin: PANEL_MARGIN,
})
// Focus the input and close on Escape or outside pointer-down while open.
useEffect(() => {
if (!noteOpen) return
inputRef.current?.focus()
const onPointerDown = (e: PointerEvent) => {
if (!(e.target instanceof Node)) return
if (triggerRef.current?.contains(e.target) === true) return
if (panelRef.current?.contains(e.target) === true) return
closeNote()
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeNote()
}
document.addEventListener('pointerdown', onPointerDown)
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('pointerdown', onPointerDown)
document.removeEventListener('keydown', onKeyDown)
}
}, [noteOpen, closeNote])
// Return focus to the trigger only when the panel actually closes, not on the
// initial mount (a freshly rendered message with a recorded rating must not
// pull focus into its action row).
const wasOpen = useRef(false)
useEffect(() => {
if (noteOpen) { wasOpen.current = true; return }
if (wasOpen.current) triggerRef.current?.focus()
wasOpen.current = false
}, [noteOpen])
const likeLabel = rating === 'positive' ? t('action.likeActive') : t('action.like')
const dislikeLabel = rating === 'negative' ? t('action.dislikeActive') : t('action.dislike')
@@ -116,38 +259,66 @@ export function MessageFeedbackActions({ messageId, ensure, rate, toggle, clearN
<IconDislikeOutline16 />
</button>
</Tooltip>
{rating !== undefined && !noteOpen && (
<button type="button" className={css.noteOpen} onClick={openNote}>
{rating !== undefined && (
<button
ref={triggerRef}
type="button"
className={css.noteOpen}
aria-haspopup="dialog"
aria-expanded={noteOpen}
onClick={toggleNote}
>
{item?.note === undefined ? t('note.open') : item.note}
</button>
)}
{rating !== undefined && noteOpen && (
<span className={css.noteEditor}>
{rowFailure === null && loadFailed && (
<span className={css.failure} role="status">{t('error.load')}</span>
)}
{rowFailure !== null && <span className={css.failure} role="status">{rowFailure}</span>}
{/* A note-save failure normally lives inside the panel, beside the buttons
that produced it. Whenever the panel is not on screen it falls back to
the row instead: the rating may have disappeared underneath an open
editor (another client retracts the feedback, a `version-conflict`
reply commits `current: null`, the item goes away), or the human may
have closed the panel before a slow save came back. Either way the row
reports that the save did not land rather than dropping it. */}
{!(rating !== undefined && noteOpen) && noteFailure !== null && (
<span className={css.failure} role="status">{noteFailure}</span>
)}
{rating !== undefined && noteOpen && createPortal(
<div
ref={panelRef}
className={css.notePanel}
role="dialog"
aria-label={t('note.dialog')}
style={pos ?? MEASURE_STYLE}
>
<textarea
ref={inputRef}
className={css.noteInput}
aria-label={t('note.aria')}
placeholder={t('note.placeholder')}
value={draft}
rows={2}
rows={3}
onChange={(event) => { setDraft(event.target.value) }}
/>
<button
type="button"
className={css.noteSave}
disabled={pending}
onClick={() => { onSaveNote(rating) }}
>
{t('note.save')}
</button>
<button type="button" className={css.noteCancel} onClick={() => { setNoteOpen(false) }}>
{t('note.cancel')}
</button>
</span>
<div className={css.noteActions}>
<button
type="button"
className={css.noteSave}
disabled={pending}
onClick={() => { onSaveNote(rating) }}
>
{t('note.save')}
</button>
<button type="button" className={css.noteCancel} onClick={closeNote}>
{t('note.cancel')}
</button>
</div>
{noteFailure !== null && <span className={css.failure} role="status">{noteFailure}</span>}
</div>,
document.body,
)}
{failure === null && loadFailed && (
<span className={css.failure} role="status">{t('error.load')}</span>
)}
{failure !== null && <span className={css.failure} role="status">{failure}</span>}
</>
)
}
@@ -7,6 +7,7 @@ export const zh = {
'action.dislike': '有问题的回答',
'action.dislikeActive': '取消标记',
'note.open': '补充说明',
'note.dialog': '反馈',
'note.placeholder': '这条回答哪里好,或哪里有问题?(可选)',
'note.save': '保存',
'note.cancel': '取消',
@@ -33,6 +34,7 @@ export const en = {
'action.dislike': 'Bad response',
'action.dislikeActive': 'Remove rating',
'note.open': 'Add a note',
'note.dialog': 'Feedback',
'note.placeholder': 'What was good, or what went wrong? (optional)',
'note.save': 'Save',
'note.cancel': 'Cancel',
@@ -8,7 +8,7 @@
*/
import { useSyncExternalStore } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
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 { MessageId } from '@deepseek-ai/dsh-client-connection/client'
@@ -246,4 +246,494 @@ describe('MessageFeedbackActions', () => {
await waitFor(() => { expect(ui.getByText(zh['error.generic'])).toBeTruthy() })
expect(ui.queryByText(zh['error.load'])).toBeNull()
})
it('portals the note editor to the document body, not into the actions row', () => {
const ui = mount({ current: item({ rating: 'positive' }) })
fireEvent.click(ui.getByText(zh['note.open']))
// The editor must float above the transcript (escaping the conversation
// column's overflow clip), so it renders through a portal to document.body
// rather than inline inside the component's own container.
const panel = ui.getByRole('dialog')
expect(panel).toBeTruthy()
expect(ui.container.querySelector('[role="dialog"]')).toBeNull()
expect(document.body.contains(panel)).toBe(true)
})
it('closes the note popover on Escape', () => {
const ui = mount({ current: item({ rating: 'positive' }) })
fireEvent.click(ui.getByText(zh['note.open']))
expect(ui.getByRole('dialog')).toBeTruthy()
fireEvent.keyDown(document, { key: 'Escape' })
expect(ui.queryByRole('dialog')).toBeNull()
})
it('closes the note popover on an outside pointer-down', () => {
const ui = mount({ current: item({ rating: 'positive' }) })
fireEvent.click(ui.getByText(zh['note.open']))
expect(ui.getByRole('dialog')).toBeTruthy()
fireEvent.pointerDown(document.body)
expect(ui.queryByRole('dialog')).toBeNull()
})
it('keeps the note popover open on a pointer-down inside it', () => {
const ui = mount({ current: item({ rating: 'positive' }) })
fireEvent.click(ui.getByText(zh['note.open']))
const panel = ui.getByRole('dialog')
expect(panel).toBeTruthy()
fireEvent.pointerDown(panel)
expect(ui.getByRole('dialog')).toBeTruthy()
})
it('does not close the note popover on a pointer-down on its trigger', () => {
const ui = mount({ current: item({ rating: 'positive' }) })
fireEvent.click(ui.getByText(zh['note.open']))
expect(ui.getByRole('dialog')).toBeTruthy()
// The trigger is inside the panel's own region, so pressing it must not be
// treated as an outside click; the toggle click below then closes it.
fireEvent.pointerDown(ui.getByText(zh['note.open']))
expect(ui.getByRole('dialog')).toBeTruthy()
})
it('toggles the note popover closed and open from its trigger', () => {
const ui = mount({ current: item({ rating: 'positive' }) })
fireEvent.click(ui.getByText(zh['note.open']))
expect(ui.getByRole('dialog')).toBeTruthy()
expect(ui.getByLabelText(zh['note.aria'])).toBeTruthy()
fireEvent.click(ui.getByText(zh['note.open']))
expect(ui.queryByRole('dialog')).toBeNull()
fireEvent.click(ui.getByText(zh['note.open']))
expect(ui.getByRole('dialog')).toBeTruthy()
})
it('ignores keys other than Escape while the popover is open', () => {
const ui = mount({ current: item({ rating: 'positive' }) })
fireEvent.click(ui.getByText(zh['note.open']))
expect(ui.getByRole('dialog')).toBeTruthy()
fireEvent.keyDown(document, { key: 'Enter' })
expect(ui.getByRole('dialog')).toBeTruthy()
})
it('publishes no rating-state after the row unmounts mid-flight', async () => {
// Directly exercise the early-return of a rating settle once the control has
// unmounted: the promise resolution must not touch React state.
let release = (): void => {}
const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
release = () => { resolve({ ok: true as const }) }
})
const view: MessageFeedbackView = { status: 'ready', items: new Map(), error: null }
const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
useSyncExternalStore(() => () => {}, () => select(view))) as never
const props = {
messageId: MSG,
ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
rate: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
toggle: vi.fn(() => gate),
clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
useFeedback,
t,
} as unknown as Parameters<typeof MessageFeedbackActions>[0]
const ui = render(<MessageFeedbackActions {...props} />)
const errors: unknown[] = []
const onError = (event: ErrorEvent): void => { errors.push(event.error) }
window.addEventListener('error', onError)
fireEvent.click(ui.getByLabelText(zh['action.like']))
ui.unmount()
release()
await gate
window.removeEventListener('error', onError)
expect(errors).toEqual([])
})
it('publishes no note-state after the row unmounts mid-save', async () => {
// Same unmount early-return for the note-save settle path: resolving the
// save promise after unmount must not touch React state.
let release = (): void => {}
const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
release = () => { resolve({ ok: true as const }) }
})
const view: MessageFeedbackView = { status: 'ready', items: new Map([[MSG, item({ rating: 'positive' })]]), error: null }
const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
useSyncExternalStore(() => () => {}, () => select(view))) as never
const props = {
messageId: MSG,
ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
rate: vi.fn(() => gate),
toggle: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
useFeedback,
t,
} as unknown as Parameters<typeof MessageFeedbackActions>[0]
const errors: unknown[] = []
const onError = (event: ErrorEvent): void => { errors.push(event.error) }
window.addEventListener('error', onError)
const ui = render(<MessageFeedbackActions {...props} />)
fireEvent.click(ui.getByText(zh['note.open']))
fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'hi' } })
fireEvent.click(ui.getByText(zh['note.save']))
ui.unmount()
release()
await gate
window.removeEventListener('error', onError)
expect(errors).toEqual([])
})
it('ignores a pointer-down whose target is not a DOM node', () => {
const ui = mount({ current: item({ rating: 'positive' }) })
fireEvent.click(ui.getByText(zh['note.open']))
expect(ui.getByRole('dialog')).toBeTruthy()
// The outside-click guard returns without closing when the event target is
// not a DOM node. `document.dispatchEvent` delivers straight to the
// document listener, and a non-Node target is not `instanceof Node`.
const event = new MouseEvent('pointerdown', { bubbles: true })
Object.defineProperty(event, 'target', { configurable: true, value: { notANode: true } })
document.dispatchEvent(event)
expect(ui.getByRole('dialog')).toBeTruthy()
})
it('returns focus to the trigger when the popover closes', () => {
const ui = mount({ current: item({ rating: 'positive' }) })
const trigger = ui.getByText(zh['note.open'])
fireEvent.click(trigger)
expect(ui.getByRole('dialog')).toBeTruthy()
fireEvent.keyDown(document, { key: 'Escape' })
// Closing hands focus back, so a keyboard user resumes on the row they
// came from rather than at the document root.
expect(ui.queryByRole('dialog')).toBeNull()
expect(document.activeElement).toBe(trigger)
})
it('does not pull focus when an already-rated message mounts', () => {
// The `wasOpen` guard exists for this: a transcript of already-rated
// messages must not drag focus into an action row as each one mounts.
// Only a real open-then-close returns focus.
const elsewhere = document.createElement('button')
document.body.append(elsewhere)
elsewhere.focus()
mount({ current: item({ rating: 'positive' }) })
expect(document.activeElement).toBe(elsewhere)
elsewhere.remove()
})
it('drops a stale save failure when the popover is reopened', async () => {
// The failure belongs to the editing session that produced it: reopening
// reseeds the draft from the stored note, so a carried-over error would
// describe an attempt the new draft never made.
const ui = mount({
current: item({ rating: 'positive' }),
rateResult: { ok: false, error: { code: 'note-too-large', message: 'too long' } },
})
fireEvent.click(ui.getByText(zh['note.open']))
fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'x'.repeat(20) } })
fireEvent.click(ui.getByText(zh['note.save']))
await waitFor(() => { expect(ui.getByText(zh['error.generic'])).toBeTruthy() })
fireEvent.keyDown(document, { key: 'Escape' })
fireEvent.click(ui.getByText(zh['note.open']))
expect(ui.queryByText(zh['error.generic'])).toBeNull()
})
it('keeps a save failure visible when the rating disappears underneath it', async () => {
// Another client retracts the feedback while the editor is open: the
// controller commits `current: null`, the item goes away, and the panel
// unmounts. The failure must not vanish with it, so it falls back to the row.
let release = (): void => {}
const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
release = () => { resolve({ ok: false, error: { code: 'target-not-found', message: 'gone' } }) }
})
const view: MessageFeedbackView = {
status: 'ready',
items: new Map([[MSG, item({ rating: 'positive' })]]),
error: null,
}
let notify = (): void => {}
const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
useSyncExternalStore((cb) => { notify = cb; return () => {} }, () => select(view))) as never
const props = {
messageId: MSG,
ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
rate: vi.fn(() => gate),
toggle: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
useFeedback,
t,
} as unknown as Parameters<typeof MessageFeedbackActions>[0]
const ui = render(<MessageFeedbackActions {...props} />)
fireEvent.click(ui.getByText(zh['note.open']))
fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'hi' } })
fireEvent.click(ui.getByText(zh['note.save']))
// The retract lands first, then the save rejects.
view.items = new Map()
notify()
release()
await gate
await waitFor(() => { expect(ui.getByText(zh['error.generic'])).toBeTruthy() })
expect(ui.queryByRole('dialog')).toBeNull()
})
it('ignores a save that resolves after its editing session ended', async () => {
// Closing and reopening starts a new session. A late success from the old
// one must not shut the panel the human just opened.
let release = (): void => {}
const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
release = () => { resolve({ ok: true as const }) }
})
const view: MessageFeedbackView = {
status: 'ready',
items: new Map([[MSG, item({ rating: 'positive' })]]),
error: null,
}
const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
useSyncExternalStore(() => () => {}, () => select(view))) as never
const props = {
messageId: MSG,
ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
rate: vi.fn(() => gate),
toggle: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
useFeedback,
t,
} as unknown as Parameters<typeof MessageFeedbackActions>[0]
const ui = render(<MessageFeedbackActions {...props} />)
fireEvent.click(ui.getByText(zh['note.open']))
fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'first' } })
fireEvent.click(ui.getByText(zh['note.save']))
// Abandon that session and start another before the save lands.
fireEvent.keyDown(document, { key: 'Escape' })
fireEvent.click(ui.getByText(zh['note.open']))
expect(ui.getByRole('dialog')).toBeTruthy()
release()
await gate
// Flush the `.then` continuation and the render it would cause. Asserted
// directly rather than through `waitFor`, which would retry past a panel
// that the stale result closed.
await act(async () => { await Promise.resolve() })
expect(ui.getByRole('dialog')).toBeTruthy()
// The reply is discarded, but the request is no longer in flight, so the
// controls must not stay disabled: `pending` gates the rating buttons and
// Save, and leaving it set locks this message's row until it remounts.
expect(ui.getByLabelText(zh['action.likeActive']).hasAttribute('disabled')).toBe(false)
expect(ui.getByLabelText(zh['action.dislike']).hasAttribute('disabled')).toBe(false)
expect(ui.getByText(zh['note.save']).hasAttribute('disabled')).toBe(false)
})
it('reports a save that fails after the human closed the panel', async () => {
// A slow save that rejects once the panel is gone must not be swallowed:
// the human would otherwise believe the note was stored. With no panel to
// show it in, the row carries the notice.
let release = (): void => {}
const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
release = () => {
resolve({ ok: false, error: { code: 'target-not-found', message: 'gone' } })
}
})
const view: MessageFeedbackView = {
status: 'ready',
items: new Map([[MSG, item({ rating: 'positive' })]]),
error: null,
}
const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
useSyncExternalStore(() => () => {}, () => select(view))) as never
const props = {
messageId: MSG,
ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
rate: vi.fn(() => gate),
toggle: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
useFeedback,
t,
} as unknown as Parameters<typeof MessageFeedbackActions>[0]
const ui = render(<MessageFeedbackActions {...props} />)
fireEvent.click(ui.getByText(zh['note.open']))
fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'hi' } })
fireEvent.click(ui.getByText(zh['note.save']))
// Walk away before the reply lands, and leave it closed.
fireEvent.keyDown(document, { key: 'Escape' })
expect(ui.queryByRole('dialog')).toBeNull()
release()
await gate
await act(async () => { await Promise.resolve() })
expect(ui.getByText(zh['error.generic'])).toBeTruthy()
})
it('does not write an abandoned session\'s failure into a reopened panel', async () => {
// The old request rejects after the panel was closed and reopened, so the
// new session owns the panel. Its draft was not the one that failed, so the
// stale error must not be shown there; it belongs to the abandoned session.
let release = (): void => {}
const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
release = () => {
resolve({ ok: false, error: { code: 'target-not-found', message: 'gone' } })
}
})
const view: MessageFeedbackView = {
status: 'ready',
items: new Map([[MSG, item({ rating: 'positive' })]]),
error: null,
}
const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
useSyncExternalStore(() => () => {}, () => select(view))) as never
const props = {
messageId: MSG,
ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
rate: vi.fn(() => gate),
toggle: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
useFeedback,
t,
} as unknown as Parameters<typeof MessageFeedbackActions>[0]
const ui = render(<MessageFeedbackActions {...props} />)
fireEvent.click(ui.getByText(zh['note.open']))
fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'first' } })
fireEvent.click(ui.getByText(zh['note.save']))
// Abandon that session and start another before the save rejects; unlike
// the closed-and-left case, a new panel is now on screen.
fireEvent.keyDown(document, { key: 'Escape' })
fireEvent.click(ui.getByText(zh['note.open']))
expect(ui.getByRole('dialog')).toBeTruthy()
release()
await gate
await act(async () => { await Promise.resolve() })
// The stale failure names a draft the new session never sent, so it stays
// out of the reopened panel's status area.
expect(ui.queryByText(zh['error.generic'])).toBeNull()
expect(ui.getByRole('dialog')).toBeTruthy()
})
it('resyncs an untouched reopened draft to the note that just committed', async () => {
// The reopened session seeded from the note as it read before the save
// committed, so an untouched draft would show stale text and the next save
// could overwrite what just landed.
let release = (): void => {}
const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
release = () => { resolve({ ok: true as const }) }
})
const view: MessageFeedbackView = {
status: 'ready',
items: new Map([[MSG, item({ rating: 'positive', note: 'old' })]]),
error: null,
}
const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
useSyncExternalStore(() => () => {}, () => select(view))) as never
const props = {
messageId: MSG,
ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
rate: vi.fn(() => gate),
toggle: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
useFeedback,
t,
} as unknown as Parameters<typeof MessageFeedbackActions>[0]
const ui = render(<MessageFeedbackActions {...props} />)
fireEvent.click(ui.getByText('old'))
fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'saved text' } })
fireEvent.click(ui.getByText(zh['note.save']))
// Close and reopen before the save lands: the new draft is seeded from the
// still-stale stored note.
fireEvent.keyDown(document, { key: 'Escape' })
fireEvent.click(ui.getByText('old'))
expect((ui.getByLabelText(zh['note.aria']) as HTMLTextAreaElement).value).toBe('old')
release()
await gate
await act(async () => { await Promise.resolve() })
expect((ui.getByLabelText(zh['note.aria']) as HTMLTextAreaElement).value).toBe('saved text')
})
it('leaves a reopened draft alone once the human has edited it', async () => {
// The opposite arm: an edited draft belongs to the human, so a late save
// must not overwrite what they are typing.
let release = (): void => {}
const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
release = () => { resolve({ ok: true as const }) }
})
const view: MessageFeedbackView = {
status: 'ready',
items: new Map([[MSG, item({ rating: 'positive', note: 'old' })]]),
error: null,
}
const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
useSyncExternalStore(() => () => {}, () => select(view))) as never
const props = {
messageId: MSG,
ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
rate: vi.fn(() => gate),
toggle: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
useFeedback,
t,
} as unknown as Parameters<typeof MessageFeedbackActions>[0]
const ui = render(<MessageFeedbackActions {...props} />)
fireEvent.click(ui.getByText('old'))
fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'saved text' } })
fireEvent.click(ui.getByText(zh['note.save']))
fireEvent.keyDown(document, { key: 'Escape' })
fireEvent.click(ui.getByText('old'))
fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'my new words' } })
release()
await gate
await act(async () => { await Promise.resolve() })
expect((ui.getByLabelText(zh['note.aria']) as HTMLTextAreaElement).value).toBe('my new words')
})
})
@@ -0,0 +1,93 @@
/**
* Feedback controls stylesheet contract, asserted against the CSS text on disk.
*
* A `--dsw-*` name the theme never declares fails silently, and for this sheet
* it failed loudly in the product: `border`, `background`, and the primary
* button's fill and label each named a token that does not exist, so every one
* of those declarations was invalid at computed-value time and dropped. The
* note editor shipped with no border and no surface, and its Save button with
* neither fill nor readable label. Nothing downstream reports this the sheet
* parses, the classes attach, and the DOM snapshots are unchanged.
*
* The editor is a popover portaled to `document.body` and fixed-positioned
* from the note trigger's rect, so it never enters the IconActions row's flex
* layout at all the row keeps its single 28px line of icons and the note
* trigger, and no wrapping (`flex-wrap`) or `order` is needed for it. The
* width-independent half of that contract is asserted here (the panel is a
* fixed portal, not an inline flex item); the resulting geometry is measured
* in a real engine by `apps/web/tests/message-feedback-layout`.
*/
import { readdirSync, readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const css = readFileSync(
fileURLToPath(new URL('../src/client/MessageFeedbackActions.module.css', import.meta.url)),
'utf8',
)
// The theme package maps `./styles/*` to `./src/styles/*`, so the declarations
// stay on the source plane rather than needing a build. Every theme sheet, not
// just the platform tokens: font and scrollbar variables are declared in
// siblings, and a gate reading one file would call their names undeclared.
const tokens = readdirSync(fileURLToPath(new URL('../../ui-theme/src/styles/', import.meta.url)))
.filter(name => name.endsWith('.css'))
.map(name => readFileSync(fileURLToPath(new URL(`../../ui-theme/src/styles/${name}`, import.meta.url)), 'utf8'))
.join('\n')
/**
* The declarations of one top-level rule, by selector.
* @param selector - the class selector to read, including its leading dot.
* @returns the rule's declaration text.
*/
function block(selector: string): string {
const match = new RegExp(`^\\${selector} \\{([^}]*)\\}`, 'm').exec(css)
if (match === null) throw new Error(`MessageFeedbackActions.module.css has no \`${selector}\` rule`)
return match[1] ?? ''
}
describe('MessageFeedbackActions theme styles', () => {
it('names only theme variables the token sheet defines', () => {
// The regression that motivated this file. An undeclared custom property
// has no fallback and does not inherit a usable value: the entire
// declaration is thrown away, so the control renders as if the line had
// never been written. Every theme-variable prefix the sheets actually use,
// not just `--dsw-`: a `--dsh-` name reads as a plausible sibling and would
// otherwise slip past into an invalid declaration.
const named = [...css.matchAll(/var\((--(?:dsw|dsh|ds)-[a-z0-9-]+)/g)].map(match => match[1])
// Vacuity guard: the sheet has to actually name tokens, or the filter below
// is satisfied by an empty list and this test proves nothing.
expect(named.length).toBeGreaterThan(5)
const undeclared = [...new Set(named)].filter(name => !tokens.includes(` ${String(name)}:`))
expect(undeclared).toEqual([])
})
it('never falls back to a literal colour', () => {
// A token that resolves is never the problem; an undeclared one takes this
// branch, and a literal here is a single colour for both themes.
expect(css).not.toMatch(/var\(--dsw-[a-z0-9-]+\s*,\s*(?:#|rgb|rgba|hsl|hsla)/)
})
it('keeps the note editor out of the row as a fixed portal, not a flex item', () => {
// The editor is a popover portaled to document.body, so the IconActions row
// never has to grow or wrap around it. Fixed positioning comes from the
// placement code (inline `left`/`top`), not a class, so only `position:
// fixed` and the elevated surface live in the sheet — plus the absence of a
// flex rule on the panel, which would resurrect the row-overflow defect an
// inline editor had. The row stays one 28px line, so a fixed `width` on the
// panel is fine (it floats, it does not compete for row space).
expect(block('.notePanel')).toMatch(/position:\s*fixed/)
// The panel flex-sets its own children (textarea over buttons), which is
// fine. What must be absent is the flex-SIZING that made an inline editor a
// row item: grow/shrink/basis (or the `flex:` shorthand) would let it rejoin
// the IconActions layout, resurrecting the overflow defect.
expect(block('.notePanel')).not.toMatch(/flex-(?:grow|shrink|basis)\s*:/)
expect(block('.notePanel')).not.toMatch(/^\s*flex\s*:/m)
})
it('closes every block, so no rule is swallowed by the one above it', () => {
// A missing `}` is not a parse error: every rule after it silently becomes
// part of the block above, and the controls would paint unstyled.
const bare = css.replace(/\/\*[\s\S]*?\*\//g, '')
expect((bare.match(/\}/g) ?? []).length).toBe((bare.match(/\{/g) ?? []).length)
})
})
@@ -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-primitives/README.md
README.md: 9a5b33e6b2ecae0652d640d2a6927e3f1d05b1a1
README.zh.md: a94935235790504bbeb7f5091e8c8fba86e1c903
README.md: a675b3cd0aa9e09e243b69065110e1d2b67ff1d9
README.zh.md: aa67993ec879635fc0677501665d2e23de996dcf
+1 -1
View File
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Pure React atoms (zero cordis): StateDot, DisclosureRow, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the Toast transient banner, the OnboardingSurface first-run takeover (body-portaled mask + opaque stage that holds `#root` inert for exactly its own lifetime), the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), TerminalBlock, DiffBlock, ReadBlock, SearchBlock, and WebBlock.
Pure React atoms (zero cordis): StateDot, DisclosureRow, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the Toast transient banner, the OnboardingSurface first-run takeover (body-portaled mask + opaque stage that holds `#root` inert for exactly its own lifetime), the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), the `useAnchoredPosition` hook that holds a fixed-position floating panel under its anchor (measure, offset, clamp inside the viewport margin, re-placed on capture-phase scroll, window resize, and the panel's own size changes), TerminalBlock, DiffBlock, ReadBlock, SearchBlock, and WebBlock.
## Hover cards
+1 -1
View File
@@ -2,7 +2,7 @@
[English](README.md) | 中文
纯 React 原子组件(零 cordis):StateDot、DisclosureRow、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、Toast 短时横幅、OnboardingSurface 首次使用接管层(portal 到 body 的遮罩加不透明展示层,在且仅在自身生命周期内保持 `#root``inert`)、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` 钩子(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量)、TerminalBlock、DiffBlock、ReadBlock、SearchBlock,以及 WebBlock。
纯 React 原子组件(零 cordis):StateDot、DisclosureRow、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、Toast 短时横幅、OnboardingSurface 首次使用接管层(portal 到 body 的遮罩加不透明展示层,在且仅在自身生命周期内保持 `#root``inert`)、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` 钩子(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量)、`useAnchoredPosition` 钩子(让固定定位的浮动面板跟住锚点:测量、偏移、按视口边距钳制,并在捕获阶段滚动、窗口缩放与面板自身尺寸变化时重新定位)、TerminalBlock、DiffBlock、ReadBlock、SearchBlock,以及 WebBlock。
## 悬浮卡片
@@ -13,6 +13,8 @@ export { Input } from './Input.tsx'
export { Menu } from './Menu.tsx'
export type { MenuEntry, MenuItem, MenuSeparator, MenuLabel } from './Menu.tsx'
export { useAnchoredMaxHeight } from './useAnchoredMaxHeight.ts'
export { useAnchoredPosition } from './useAnchoredPosition.ts'
export type { AnchoredPositionOptions } from './useAnchoredPosition.ts'
export { useDismissOnOutsidePointer } from './useDismissOnOutsidePointer.ts'
export { HoverCard } from './HoverCard.tsx'
export { Modal } from './Modal.tsx'
@@ -0,0 +1,81 @@
/**
* Keep a fixed-position floating element anchored to a trigger.
*
* A portaled panel is positioned from its anchor's viewport rect, which stops
* being true the moment anything scrolls or the window resizes. This owns that
* one concern: measure the anchor, offset the panel below it, clamp the result
* inside the viewport, and re-run on scroll (capture phase, so scrollers nested
* inside the page are caught too), on resize, and on the panel's own size
* changes while the element is open.
* @module @deepseek-ai/dsh-client-ui-primitives/useAnchoredPosition
*/
import { useLayoutEffect, useState, type CSSProperties, type RefObject } from 'react'
/** Inputs for {@link useAnchoredPosition}. */
export interface AnchoredPositionOptions {
/** Whether the floating element is mounted and should track its anchor. */
open: boolean
/** The element the panel is placed from. */
anchorRef: RefObject<HTMLElement | null>
/** The floating element, measured so the clamp uses real dimensions. */
panelRef: RefObject<HTMLElement | null>
/** Distance kept between the anchor's bottom edge and the panel's top. */
gap: number
/** Distance kept between the panel and each viewport edge. */
margin: number
}
/**
* Track an anchor and return the panel's fixed coordinates.
* @param options - the open state, the two refs, and the gap/margin distances.
* @returns `left`/`top` for the panel, or `null` before the first measurement.
*/
export function useAnchoredPosition(options: AnchoredPositionOptions): CSSProperties | null {
const { open, anchorRef, panelRef, gap, margin } = options
const [position, setPosition] = useState<CSSProperties | null>(null)
useLayoutEffect(() => {
if (!open) {
setPosition(null)
return
}
const place = () => {
/* v8 ignore start -- geometry read from real layout: jsdom reports zero
offset sizes, so the positive-size clamp arms are exercised by browser
scenarios rather than unit tests. */
const rect = anchorRef.current?.getBoundingClientRect()
if (rect === undefined) return
const panel = panelRef.current
const width = panel?.offsetWidth ?? 0
const height = panel?.offsetHeight ?? 0
let left = rect.left
let top = rect.bottom + gap
if (width > 0) left = Math.min(Math.max(left, margin), window.innerWidth - width - margin)
if (height > 0) top = Math.min(Math.max(top, margin), window.innerHeight - height - margin)
/* v8 ignore stop */
setPosition({ left, top })
}
// The first run measures the panel in the same commit that opened it, so
// the clamp uses real dimensions before anything paints.
place()
window.addEventListener('scroll', place, true)
window.addEventListener('resize', place)
// The panel's own height changes without either event — a status line
// appearing inside it, or a `resize: vertical` textarea dragged taller —
// and a stale clamp would let a panel near the bottom edge cross the
// margin it is supposed to respect. The guard keeps the hook usable where
// `ResizeObserver` is absent, which is how jsdom runs.
const panel = panelRef.current
let observer: ResizeObserver | null = null
if (typeof ResizeObserver !== 'undefined' && panel !== null) {
observer = new ResizeObserver(place)
observer.observe(panel)
}
return () => {
observer?.disconnect()
window.removeEventListener('scroll', place, true)
window.removeEventListener('resize', place)
}
}, [open, anchorRef, panelRef, gap, margin])
return position
}
@@ -0,0 +1,111 @@
// @vitest-environment jsdom
/**
* `useAnchoredPosition` wiring: a floating panel is placed from its anchor and
* keeps tracking it while open.
*
* The geometry itself needs real layout, which jsdom does not provide the
* browser layout scenario in `apps/web/tests/message-feedback-layout.e2e.ts`
* owns that. What is asserted here is the wiring the clamp depends on: the
* listeners and the panel-size observer are attached while open and released on
* close, a size change replays the placement, and the hook still works where
* `ResizeObserver` does not exist.
*/
import { useRef } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { useAnchoredPosition } from '../src/useAnchoredPosition.ts'
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
/** One recorded `ResizeObserver` instance, so a test can drive its callback. */
interface Recorded {
callback: ResizeObserverCallback
observed: Element[]
disconnected: boolean
}
/**
* Install a recording `ResizeObserver` double.
* @returns the list every constructed observer registers itself in.
*/
function stubResizeObserver(): Recorded[] {
const made: Recorded[] = []
vi.stubGlobal('ResizeObserver', class {
private readonly record: Recorded
constructor(callback: ResizeObserverCallback) {
this.record = { callback, observed: [], disconnected: false }
made.push(this.record)
}
observe(element: Element) { this.record.observed.push(element) }
disconnect() { this.record.disconnected = true }
})
return made
}
/**
* Host component that anchors a panel and reports the computed position.
* @param props - whether the panel is open.
* @returns the anchor and, while open, the panel carrying the position.
*/
function Host({ open }: { open: boolean }) {
const anchorRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const position = useAnchoredPosition({ open, anchorRef, panelRef, gap: 4, margin: 12 })
return (
<>
<button ref={anchorRef} type="button">anchor</button>
{open && <div ref={panelRef} data-testid="panel" style={position ?? { visibility: 'hidden' }} />}
</>
)
}
describe('useAnchoredPosition', () => {
it('observes the panel while open and disconnects when it closes', () => {
const made = stubResizeObserver()
const ui = render(<Host open />)
expect(made).toHaveLength(1)
expect(made[0]?.observed).toEqual([ui.getByTestId('panel')])
expect(made[0]?.disconnected).toBe(false)
ui.rerender(<Host open={false} />)
expect(made[0]?.disconnected).toBe(true)
})
it('replaces the panel when its own size changes', () => {
const made = stubResizeObserver()
render(<Host open />)
const before = made[0]?.callback
expect(before).toBeDefined()
// A status line appearing inside the panel, or a dragged textarea, changes
// the height without a scroll or resize event; the observer is the only
// thing that notices, so driving its callback must not throw.
expect(() => { before?.([], {} as ResizeObserver) }).not.toThrow()
})
it('still places the panel where ResizeObserver does not exist', () => {
// jsdom's own condition, and any host without the API: the hook must fall
// back to scroll and resize rather than fail at mount.
vi.stubGlobal('ResizeObserver', undefined)
expect(() => render(<Host open />)).not.toThrow()
})
it('attaches no listeners while the element is closed', () => {
const made = stubResizeObserver()
const add = vi.spyOn(window, 'addEventListener')
render(<Host open={false} />)
expect(made).toHaveLength(0)
expect(add.mock.calls.filter(([type]) => type === 'scroll' || type === 'resize')).toEqual([])
add.mockRestore()
})
})
@@ -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/subagent/subagent-claude-code/README.md
README.md: 67a8cf199a2e85d975a4df90f56d5dbafb9f1e56
README.zh.md: 0b8f2f285924de161cefd21c422fd40ac3597ad7
README.md: 0260d9c82dee82541e5b60ff7fe2c323cf9b7331
README.zh.md: 0a301ff53589112afe3f7f3d42d334fbebd04083
@@ -8,15 +8,15 @@ This package registers a Profile-named Claude Code subagent provider whose defau
`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It creates one private `AbortController`, calls the official SDK `query()`, and publishes the run only after the SDK's `spawnClaudeCodeProcess` hook has supplied a live CLI handle owned by [`dsh-subprocess`](../../subprocess/subprocess/README.md). A failure or cancellation before publication closes the query, terminates any acquired process tree, waits for it to exit, and rejects `start()`.
The SDK receives the exact concatenated text task. The provider iterates the complete SDK message stream and accepts only a `result` message with `subtype: "success"`, `is_error: false`, and a nonblank `result`, followed by normal iterator completion. Every SDK error subtype, an error-marked success, a missing answer, iterator failure, protocol failure, or process failure maps to `error`; the provider produces neither `max-tokens` nor `refusal`.
The SDK receives the exact concatenated text task. The provider iterates the complete SDK message stream and accepts only a `result` message with `subtype: "success"`, `is_error: false`, and a nonblank `result`, followed by normal iterator completion. Every failure still maps to `error`: the four error subtypes in Agent SDK 0.3.220 retain their exact category, an error-marked or blank success becomes `invalid-success`, a missing result becomes `missing-result`, an unclassified query failure becomes `unknown`, and an early CLI exit becomes `process-exit`. The diagnostic also names the current `query-start`, `query-run`, `process`, or `teardown` stage and independently includes an observed exit code and signal. The provider produces neither `max-tokens` nor `refusal`.
Local cancellation wins the result race and maps to `aborted`. `dispose()` is idempotent: it aborts the run, asks the SDK query to close, invokes the shared process-tree termination escalation, and waits for whole-tree exit. SDK graceful close expresses protocol intent; the subprocess handle remains the authority for process quiescence. Result failure and independent teardown failure remain separate.
Local cancellation wins the result race and maps to `aborted` without a failure diagnostic. `dispose()` is idempotent: it aborts the run, asks the SDK query to close, invokes the shared process-tree termination escalation, and waits for whole-tree exit. SDK graceful close expresses protocol intent; the subprocess handle remains the authority for process quiescence. Startup and teardown rejections expose the same fixed safe stage and process facts through their Error message, while the original product or Host error remains on the internal cause chain and in the Provider's Host log. Result failure and independent teardown failure remain separate.
## Native settings and interaction
The provider deliberately omits the SDK `settingSources` option. The official SDK therefore reads the host's normal user, project, and local Claude settings relative to the parent Session cwd, including native account state and product configuration. The provider neither copies nor filters those files and does not create or modify login state. The Profile-selected `permissionMode` is the one query-level override: Claude Code still owns its settings and sandbox, while the selected native mode decides how this unattended query handles permission checks.
Each query sets `persistSession: false` and disables `AskUserQuestion`. Except in bypass mode, `canUseTool` immediately denies requests that still require human approval. Plan mode also places `ExitPlanMode` in the SDK's `disallowedTools`, so native settings cannot pre-approve a transition back to execution and the model must return the completed plan as its final answer. MCP elicitation is declined, the known refusal fallback dialog is cancelled, and undeclared dialog kinds use the SDK's no-dialog failure behavior. These decisions never wait for a user interface. A permission denial or unattended callback that contributes to a failed run produces an optional `SubagentResult.diagnostic` containing only the product, effective mode, request category, decision, and fixed safe reason; the shared result boundary limits the complete text to 4096 UTF-8 bytes. Successful and locally cancelled runs do not expose the captured failure detail.
Each query sets `persistSession: false` and disables `AskUserQuestion`. Except in bypass mode, `canUseTool` immediately denies requests that still require human approval. Plan mode also places `ExitPlanMode` in the SDK's `disallowedTools`, so native settings cannot pre-approve a transition back to execution and the model must return the completed plan as its final answer. MCP elicitation is declined, the known refusal fallback dialog is cancelled, and undeclared dialog kinds use the SDK's no-dialog failure behavior. These decisions never wait for a user interface. When both facts contribute to a failed run, `SubagentResult.diagnostic` contains the structured failure line first and the latest safe permission decision second; the shared result boundary limits the complete text to 4096 UTF-8 bytes. Successful and locally cancelled runs expose neither captured fact.
## Capabilities and context
@@ -100,7 +100,7 @@ The standalone composition below shows the complete explicit capability. A Profi
The runtime dependency is pinned to `@anthropic-ai/claude-agent-sdk@0.3.220`, whose eight platform packages carry Claude Code 2.1.220. A normal install selects one payload for the current OS, CPU, and Linux libc. For the current darwin-arm64 payload, `npm pack --dry-run --json` reports 74,858,812 packed bytes and 256,908,856 unpacked bytes; other platforms may differ, and these values are disclosure rather than an installation threshold. The keyless real-product test runs the SDK-selected CLI against a loopback Messages fixture and asserts that the shared subprocess argv begins with that platform package's native executable. Loader composition proves that installing the Bundle registers only the dormant Claude Code provider and starts no product process.
Installing with optional dependencies omitted, using an unsupported platform, or losing the selected payload leaves provider registration dormant but makes the first delegation fail with the SDK's native-payload startup error. The provider neither probes a host CLI nor retries with one.
Installing with optional dependencies omitted, using an unsupported platform, or losing the selected payload leaves provider registration dormant but makes the first delegation fail at the SDK startup boundary. The caller receives the safe `query-start` / `unknown` failure fact; the native payload error remains only on the internal cause chain and in the Provider's Host log. The provider neither probes a host CLI nor retries with one.
Loader composition proves that the Bundle default, two additional named Claude instances, and the existing Codex package coexist without starting either product.
@@ -126,7 +126,7 @@ Independent of the parent request cache. Reuse depends only on Claude Code's own
#### What the model sees
Through `dsh-tool-subagent`, a foreground call gives the parent the strict final Claude Code answer or an error containing the stop reason and optional safe diagnostic for a non-completed result. A background call first returns a Job id; the generic job controls later deliver a completion notice, expose the final answer or failed status detail through `job_output`, and let `job_kill` request cancellation. Claude Code reasoning, tool activity, intermediate messages, stderr, workspace diffs, usage, product ids, tool inputs, and raw protocol payloads are not copied into the parent Session.
Through `dsh-tool-subagent`, a foreground call gives the parent the strict final Claude Code answer or an error containing the stop reason and optional safe diagnostic for a non-completed result. That diagnostic can distinguish the fixed SDK error category, lifecycle stage, and observed process outcome without copying raw product text. A background call first returns a Job id; the generic job controls later deliver a completion notice, expose the same final answer or failed status detail through `job_output`, and let `job_kill` request cancellation. Claude Code reasoning, tool activity, intermediate messages, stderr, workspace diffs, usage, product ids, tool inputs, and raw protocol payloads are not copied into the parent Session.
#### Token effect
@@ -141,7 +141,7 @@ Append-only: foreground adds one result after the reusable parent prefix, while
- **One fresh query and process per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence.
- **Static instance selection** — Profile rows fix provider names and tool bindings; calls cannot choose a provider dynamically, and every exposed tool needs a unique `toolName`.
- **Host settings are intentionally authoritative** — project and user settings can change model, tools, and behavior; the provider does not provide a filtered or hermetic production mode.
- **Authentication and account state remain native** — the Bundle supplies the CLI but does not create an account, log in, or rewrite Claude settings; configuration and authentication failures surface as startup or run errors.
- **Authentication and account state remain native** — the Bundle supplies the CLI but does not create an account, log in, or rewrite Claude settings; configuration and authentication failures surface with their lifecycle stage and the safe `unknown` fallback rather than a separate public classification.
- **The SDK platform payload is required at delegation time** — installs that omit optional dependencies, unsupported platforms, and missing or damaged payloads fail at the first query; there is no host-CLI fallback.
- **No human interaction path**`AskUserQuestion` is disabled, permission prompts are denied, MCP elicitation is declined, and blocking dialogs fail closed instead of suspending.
- **Assistant payload is final text only** — a failed run may additionally expose the separate safe diagnostic; reasoning, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local, while generic Job ids, notices, and status come from the shared job runtime.
@@ -8,15 +8,15 @@
`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。它会创建一个私有 `AbortController`,调用官方 SDK 的 `query()`,并仅在 SDK 的 `spawnClaudeCodeProcess` 钩子已经提供由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) 管理的活动 CLI 句柄后发布此次运行。若在发布前发生失败或取消,它会关闭 query、终止所有已取得的进程树并等待其退出,然后拒绝 `start()` 调用。
SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK 消息流,而且只接受满足以下条件的 `result` 消息:其 `subtype: "success"``is_error: false``result` 非空白,之后迭代器还须正常结束。所有 SDK 错误子类型、标记为错误的成功消息、缺失答案、迭代器失败、协议失败或进程失败都映射为 `error`该提供方不会产生 `max-tokens``refusal`
SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK 消息流,而且只接受满足以下条件的 `result` 消息:其 `subtype: "success"``is_error: false``result` 非空白,之后迭代器还须正常结束。所有失败仍映射为 `error`Agent SDK 0.3.220 的四种错误子类型保留准确类别;标记为错误或内容空白的成功消息成为 `invalid-success`;缺失结果成为 `missing-result`;未分类的 query 失败成为 `unknown`CLI 提前退出成为 `process-exit`。诊断还会注明当前 `query-start``query-run``process``teardown` 阶段,并分别保留已观测到的退出码与信号。该提供方不会产生 `max-tokens``refusal`
本地取消会在结果竞态中胜出并映射为 `aborted``dispose()`(资源释放)具有幂等性:它会中止此次运行、请求 SDK query 关闭、调用共享的进程树逐级终止机制,并等待整棵进程树退出。SDK 的优雅关闭只表达协议意图;进程是否完全停稳仍以子进程句柄为准。结果失败与独立的清理失败仍彼此分离。
本地取消会在结果竞态中胜出并映射为 `aborted`,且不附带失败诊断`dispose()`(资源释放)具有幂等性:它会中止此次运行、请求 SDK query 关闭、调用共享的进程树逐级终止机制,并等待整棵进程树退出。SDK 的优雅关闭只表达协议意图;进程是否完全停稳仍以子进程句柄为准。启动与清理拒绝会在 Error 消息中公开同样固定的安全阶段和进程事实,而原始产品或 Host 错误只保留在内部 cause 链与提供方的 Host 日志中。结果失败与独立的清理失败仍彼此分离。
## 原生设置与交互
提供方故意省略 SDK 的 `settingSources` 选项。因此,官方 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置,包括原生账户状态与产品配置。提供方既不复制也不过滤这些文件,也不会创建或修改登录状态。Profile 选择的 `permissionMode` 是唯一的 query 级覆盖:Claude Code 仍拥有其设置与沙箱,而所选原生模式决定这个无人值守 query 如何处理权限检查。
每次 query 都设置 `persistSession: false` 并禁用 `AskUserQuestion`。除 bypass 模式外,`canUseTool` 会立即拒绝仍需人工审批的请求。Plan 模式还会把 `ExitPlanMode` 放入 SDK 的 `disallowedTools`,因此原生 settings 无法预先放行回到执行模式的转换,模型必须把完整计划作为最终答案返回。MCP elicitation 会被拒绝,已知的拒绝回退对话会被取消,未声明的对话类型则使用 SDK 的无对话失败行为。这些决定都不会等待用户界面。若权限拒绝或无人值守回调参与一次失败运行,提供方会生成可选的 `SubagentResult.diagnostic`,其中只包含产品、有效模式、请求类别、决定与固定的安全原因;共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。成功运行与本地取消不会公开已捕获的失败说明
每次 query 都设置 `persistSession: false` 并禁用 `AskUserQuestion`。除 bypass 模式外,`canUseTool` 会立即拒绝仍需人工审批的请求。Plan 模式还会把 `ExitPlanMode` 放入 SDK 的 `disallowedTools`,因此原生 settings 无法预先放行回到执行模式的转换,模型必须把完整计划作为最终答案返回。MCP elicitation 会被拒绝,已知的拒绝回退对话会被取消,未声明的对话类型则使用 SDK 的无对话失败行为。这些决定都不会等待用户界面。当两类事实共同参与一次失败运行`SubagentResult.diagnostic` 会先写入结构化失败行,再写入最新的安全权限决定;共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。成功运行与本地取消不会公开已捕获的事实
## 能力与上下文
@@ -100,7 +100,7 @@ dsh --profile <name>
运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`,其八个平台包都携带 Claude Code 2.1.220。普通安装会按当前操作系统、CPU 及 Linux libc 选择一个载荷。对于当前 darwin-arm64 载荷,`npm pack --dry-run --json` 报告压缩包为 74,858,812 字节、解包后为 256,908,856 字节;其他平台可能不同,这些数值只用于披露而不是安装阈值。无密钥真实产品测试会让 SDK 选择 CLI,通过回环 Messages fixture 运行它,并断言共享子进程 argv 的首项就是该平台包的原生可执行文件。Loader 组合证明安装该 Bundle 只会注册休眠的 Claude Code provider,不会启动产品进程。
如果安装时省略 optional dependencies、当前平台不受支持,或所选载荷缺失,提供方注册仍保持休眠,但第一次委派会 SDK 的原生载荷启动错误失败。提供方既不会探测宿主 CLI,也不会用它重试。
如果安装时省略 optional dependencies、当前平台不受支持,或所选载荷缺失,提供方注册仍保持休眠,但第一次委派会 SDK 启动边界失败。调用方只会收到安全的 `query-start` / `unknown` 失败事实;原生载荷错误只保留在内部 cause 链和提供方 Host 日志中。提供方既不会探测宿主 CLI,也不会用它重试。
Loader 组合证明 Bundle 默认实例、两个额外命名 Claude 实例与现有 Codex 包可以共存,而且不会启动任一产品。
@@ -126,7 +126,7 @@ Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。
#### 模型看到的内容
通过 `dsh-tool-subagent`,前台调用会让父级模型看到符合严格成功条件的 Claude Code 最终答案;若结果未完成,错误中会包含终止原因和可选的安全诊断。后台调用会先返回 Job id;随后通用作业控制面会送达完成通知,通过 `job_output` 公开最终答案或失败状态 detail,并允许 `job_kill` 请求取消。Claude Code 的推理、工具活动、中间消息、stderr、工作区差异、用量信息、产品标识符、工具输入和原始协议载荷均不会复制到父会话。
通过 `dsh-tool-subagent`,前台调用会让父级模型看到符合严格成功条件的 Claude Code 最终答案;若结果未完成,错误中会包含终止原因和可选的安全诊断。该诊断可以区分固定 SDK 错误类别、生命周期阶段和已观测的进程结果,而不复制原始产品文本。后台调用会先返回 Job id;随后通用作业控制面会送达完成通知,通过 `job_output` 公开同一最终答案或失败状态 detail,并允许 `job_kill` 请求取消。Claude Code 的推理、工具活动、中间消息、stderr、工作区差异、用量信息、产品标识符、工具输入和原始协议载荷均不会复制到父会话。
#### 对 token 的影响
@@ -141,7 +141,7 @@ Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。
- **每次运行均新建一个 query 和一个进程**:不支持续接、恢复、池化、进度流或产品会话持久化。
- **静态选择实例**:Profile 配置项固定提供方名称与工具绑定;调用无法动态选择提供方,而且每个公开工具都需要唯一的 `toolName`
- **宿主设置有意保持权威**:项目和用户设置可以改变模型、工具与行为;本提供方不提供经过筛选或与宿主环境隔离的生产模式。
- **身份验证与账户状态仍由原生机制管理**:Bundle 会提供 CLI,但不会创建账户、登录或改写 Claude 设置;配置与身份验证失败会呈现为启动错误或运行错误
- **身份验证与账户状态仍由原生机制管理**:Bundle 会提供 CLI,但不会创建账户、登录或改写 Claude 设置;配置与身份验证失败会公开其生命周期阶段与安全的 `unknown` 回退,而不会增加单独的公开分类
- **委派时必须存在 SDK 平台载荷**:省略 optional dependencies 的安装、不受支持的平台以及缺失或损坏的载荷都会在第一次 query 时失败;不会回退到宿主 CLI。
- **没有人工交互路径**`AskUserQuestion` 被禁用,权限提示会被拒绝,MCP elicitation 会被拒绝,阻塞对话会快速失败而不会挂起。
- **assistant 载荷仅包含最终文本**:失败运行可以额外公开独立的安全诊断;推理、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部,通用 Job id、通知与状态来自共享作业运行时。
@@ -21,6 +21,7 @@ import {
CLAUDE_CODE_PERMISSION_MODES,
DEFAULT_CLAUDE_CODE_PERMISSION_MODE,
DEFAULT_DISPOSE_GRACE_MS,
claudeCodeStartupFailure,
startClaudeCodeRun,
type ClaudeCodePermissionMode,
type ClaudeCodeRunSpec,
@@ -83,19 +84,36 @@ class ClaudeCodeProvider implements SubagentProvider {
'subagent-claude-code: no working directory for the child — delegate from a parent session that has one',
)
}
const spec: ClaudeCodeRunSpec = {
cwd: resolveChildCwd(
let cwd: string
try {
cwd = resolveChildCwd(
'subagent-claude-code',
undefined,
parentCwd,
),
)
} catch (error: unknown) {
if (request.signal.aborted) {
throw new Error(
'subagent-claude-code: request was aborted before SDK startup',
)
}
const failure = claudeCodeStartupFailure(error)
this.ctx.logger.warn(
`subagent-claude-code "${this.name}": child start failed: %o`,
failure,
)
throw failure
}
const spec: ClaudeCodeRunSpec = {
cwd,
permissionMode: this.config.permissionMode,
env: this.config.env,
disposeGraceMs: this.config.disposeGraceMs,
spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec),
onError: (error, stopReason) => {
this.ctx.logger.warn(
`subagent-claude-code "${this.name}": child run failed (${stopReason}): ${error.message}`,
`subagent-claude-code "${this.name}": child run failed (${stopReason}): %o`,
error,
)
},
}
@@ -13,6 +13,7 @@ import type {
import {
scrubbedParentEnv,
type SubprocessHandle,
type SubprocessOutcome,
type SubprocessSpawnSpec,
} from '@deepseek-ai/dsh-subprocess'
@@ -67,8 +68,7 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess {
readonly stdin
readonly stdout
private readonly events = new EventEmitter()
private exitCodeValue: number | null = null
private signalCodeValue: NodeJS.Signals | null = null
private outcomeValue: SubprocessOutcome | undefined
private killRequested = false
/**
@@ -84,8 +84,7 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess {
this.events.on('error', () => {})
void child.done.then(
(outcome) => {
this.exitCodeValue = outcome.exitCode
this.signalCodeValue = outcome.signal
this.outcomeValue = outcome
this.events.emit('exit', outcome.exitCode, outcome.signal)
},
(error: unknown) => {
@@ -101,12 +100,17 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess {
/** Direct-child exit code, or null while running or after signal exit. */
get exitCode(): number | null {
return this.exitCodeValue
return this.outcomeValue?.exitCode ?? null
}
/** Direct-child terminating signal, if any. */
get signalCode(): NodeJS.Signals | null {
return this.signalCodeValue
return this.outcomeValue?.signal ?? null
}
/** Exact managed-process outcome after exit, or undefined while running. */
get outcome(): SubprocessOutcome | undefined {
return this.outcomeValue
}
/**
@@ -117,8 +121,7 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess {
kill(_signal: NodeJS.Signals): boolean {
if (
this.killRequested
|| this.exitCodeValue !== null
|| this.signalCodeValue !== null
|| this.outcomeValue !== undefined
) {
return false
}
+245 -72
View File
@@ -28,6 +28,7 @@ import {
import {
scrubbedParentEnv,
type SubprocessHandle,
type SubprocessOutcome,
type SubprocessSpawnSpec,
} from '@deepseek-ai/dsh-subprocess'
import {
@@ -57,6 +58,83 @@ const SUPPORTED_UNATTENDED_DIALOG_KINDS = [
'refusal_fallback_prompt',
] satisfies NonNullable<Options['supportedDialogKinds']>
type ClaudeCodeErrorSubtype = Exclude<SDKResultMessage['subtype'], 'success'>
type ClaudeCodeFailureStage =
| 'query-start'
| 'query-run'
| 'process'
| 'teardown'
type ClaudeCodeFailureCategory =
| ClaudeCodeErrorSubtype
| 'invalid-success'
| 'missing-result'
| 'process-exit'
| 'unknown'
interface ClaudeCodeFailureFacts {
readonly stage: ClaudeCodeFailureStage
readonly category: ClaudeCodeFailureCategory
readonly outcome?: SubprocessOutcome | undefined
}
function failureDiagnostic(facts: ClaudeCodeFailureFacts): string {
const fields = [
'product: Claude Code',
`stage: ${facts.stage}`,
`category: ${facts.category}`,
]
const exitCode = facts.outcome?.exitCode
if (exitCode !== null && exitCode !== undefined) {
fields.push(`exit code: ${exitCode}`)
}
const signal = facts.outcome?.signal
if (signal !== null && signal !== undefined) {
fields.push(`signal: ${signal}`)
}
return `Product subagent failure (${fields.join('; ')})`
}
class ClaudeCodeFailure extends Error {
constructor(
readonly facts: ClaudeCodeFailureFacts,
cause?: unknown,
) {
super(
`subagent-claude-code: ${failureDiagnostic(facts)}`,
cause === undefined ? undefined : { cause },
)
this.name = 'ClaudeCodeFailure'
}
}
function sdkFailureCategory(
subtype: string,
): ClaudeCodeErrorSubtype | 'unknown' {
switch (subtype) {
case 'error_during_execution':
case 'error_max_turns':
case 'error_max_budget_usd':
case 'error_max_structured_output_retries':
return subtype
default:
return 'unknown'
}
}
/**
* Hide an unpublished product startup failure behind fixed safe facts.
* @param cause - original host-side failure retained only on the Error cause chain.
* @returns a rejection safe to expose through the subagent start boundary.
*/
export function claudeCodeStartupFailure(cause: unknown): Error {
return new ClaudeCodeFailure({
stage: 'query-start',
category: 'unknown',
}, cause)
}
function unattendedDiagnostic(
mode: ClaudeCodePermissionMode,
request: 'tool permission' | 'MCP elicitation' | 'user dialog',
@@ -80,7 +158,7 @@ export interface ClaudeCodeRunSpec {
readonly disposeGraceMs: number
/** Shared subprocess service spawn operation. */
readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle
/** Diagnostic sink for a post-publication error flattened into a result. */
/** Host diagnostic sink for a product failure kept outside model-visible text. */
readonly onError?: (error: Error, stopReason: SubagentStopReason) => void
}
@@ -93,6 +171,7 @@ function thrown(value: unknown): Error {
function isAborted(signal: AbortSignal): boolean {
return signal.aborted
}
/* jscpd:ignore-end */
/**
@@ -123,15 +202,23 @@ export function textTask(prompt: readonly ContentBlock[]): string {
* @returns exact final text for a successful, non-error result.
*/
export function successfulResult(message: SDKResultMessage): string {
if (
message.subtype !== 'success'
|| message.is_error
|| message.result.trim().length === 0
) {
const detail = message.subtype === 'success'
? 'success result was marked as an error or contained no answer'
: message.errors.join('; ') || message.subtype
throw new Error(`subagent-claude-code: Claude Code failed: ${detail}`)
if (message.subtype !== 'success') {
const category = sdkFailureCategory(message.subtype)
const detail = category === 'unknown'
? undefined
: message.errors.join('; ')
throw new ClaudeCodeFailure(
{ stage: 'query-run', category },
detail === undefined || detail.length === 0
? undefined
: new Error(detail),
)
}
if (message.is_error || message.result.trim().length === 0) {
throw new ClaudeCodeFailure({
stage: 'query-run',
category: 'invalid-success',
})
}
return message.result
}
@@ -141,11 +228,13 @@ export function successfulResult(message: SDKResultMessage): string {
* iterator completion.
* @param query - published official SDK query.
* @param onPermissionDenied - records a safe fact when the SDK reports native denial.
* @param onResult - records that the SDK supplied a terminal result message.
* @returns the completed shared result.
*/
export async function consumeClaudeQuery(
query: AsyncIterable<SDKMessage>,
onPermissionDenied?: () => void,
onResult?: () => void,
): Promise<SubagentResult> {
let answer: string | undefined
for await (const message of query) {
@@ -154,10 +243,14 @@ export async function consumeClaudeQuery(
continue
}
if (message.type !== 'result') continue
onResult?.()
answer = successfulResult(message)
}
if (answer === undefined) {
throw new Error('subagent-claude-code: Claude Code ended without a result')
throw new ClaudeCodeFailure({
stage: 'query-run',
category: 'missing-result',
})
}
return {
output: [{ type: 'text', text: answer }],
@@ -169,7 +262,8 @@ export async function consumeClaudeQuery(
* Close the official query, terminate the managed process tree, and wait for
* the subprocess owner to prove it is gone.
* @param query - official SDK query, when creation reached that point.
* @param child - shared-service handle that owns the CLI process tree.
* @param child - live shared-service handle that owns the CLI process tree;
* spawn-failed handles settle at the startup boundary instead.
*/
export async function disposeClaudeCodeChild(
query: Pick<Query, 'close'> | undefined,
@@ -182,27 +276,25 @@ export async function disposeClaudeCodeChild(
failures.push(thrown(error))
}
if (child.pid > 0) {
child.terminate()
try {
await child.waitForExit()
} catch (error: unknown) {
failures.push(thrown(error))
}
}
child.terminate()
try {
await child.done
await child.waitForExit()
} catch (error: unknown) {
failures.push(thrown(error))
}
const outcome = await child.done
const firstFailure = failures[0]
if (failures.length === 1 && firstFailure !== undefined) throw firstFailure
if (failures.length > 1) {
throw new AggregateError(
failures,
'subagent-claude-code: query and process cleanup failed',
)
if (firstFailure !== undefined) {
const facts = {
stage: 'teardown',
category: 'unknown',
outcome,
} as const
const cause = failures.length === 1
? firstFailure
: new AggregateError(failures, 'Claude Code teardown failures')
throw new ClaudeCodeFailure(facts, cause)
}
}
@@ -210,14 +302,17 @@ export async function disposeClaudeCodeChild(
* Build the fixed official SDK options for one one-shot provider run.
* @param spec - Workspace, environment, process service, and disposal policy.
* @param controller - per-run cancellation owner.
* @param capture - receives the real managed child synchronously from the SDK hook.
* @param capture - receives the shared child and SDK-facing process synchronously.
* @param captureDiagnostic - receives safe facts from unattended interaction callbacks.
* @returns options that inherit native settings while disabling persistence and user questions.
*/
export function claudeQueryOptions(
spec: ClaudeCodeRunSpec,
controller: AbortController,
capture: (child: SubprocessHandle) => void,
capture: (
child: SubprocessHandle,
process: ManagedClaudeCodeProcess,
) => void,
captureDiagnostic: (diagnostic: string) => void,
): Options {
return {
@@ -266,8 +361,9 @@ export function claudeQueryOptions(
supportedDialogKinds: SUPPORTED_UNATTENDED_DIALOG_KINDS,
spawnClaudeCodeProcess: (options: SpawnOptions) => {
const child = spec.spawn(claudeSpawnSpec(options, spec.disposeGraceMs))
capture(child)
return new ManagedClaudeCodeProcess(child)
const process = new ManagedClaudeCodeProcess(child)
capture(child, process)
return process
},
}
}
@@ -295,23 +391,42 @@ export async function startClaudeCodeRun(
}
const onAbort = (): void => { requestCancel() }
request.signal.addEventListener('abort', onAbort, { once: true })
const reportFailure = (error: Error): void => {
try {
spec.onError?.(error, 'error')
} catch {
// Host diagnostic logging cannot replace the product failure.
}
}
let child: SubprocessHandle | undefined
let query: Query | undefined
let managedProcess: ManagedClaudeCodeProcess | undefined
let diagnostic: string | undefined
const captureDiagnostic = (value: string): void => {
const capturePermissionDiagnostic = (value: string): void => {
diagnostic = value
}
const prependFailureDiagnostic = (facts: ClaudeCodeFailureFacts): void => {
const failure = failureDiagnostic(facts)
diagnostic = diagnostic === undefined
? failure
: `${failure}\n${diagnostic}`
}
const captureChild = (
captured: SubprocessHandle,
process: ManagedClaudeCodeProcess,
): void => {
child = captured
managedProcess = process
}
try {
query = officialQuery({
prompt,
options: claudeQueryOptions(
spec,
controller,
(captured) => {
child = captured
},
captureDiagnostic,
captureChild,
capturePermissionDiagnostic,
),
})
if (child === undefined || child.pid <= 0) {
@@ -325,8 +440,19 @@ export async function startClaudeCodeRun(
} catch (error: unknown) {
request.signal.removeEventListener('abort', onAbort)
const cancelledBeforeCleanup = controller.signal.aborted
// Let child.done publish a concurrently observed exit before classification.
await Promise.resolve()
const startupOutcome = managedProcess?.outcome
const startupFacts = {
stage: 'query-start',
category: 'unknown',
outcome: startupOutcome,
} as const
const startupFailure = (cause: unknown = error): ClaudeCodeFailure => new ClaudeCodeFailure(
startupFacts,
thrown(cause),
)
requestCancel()
const startupError = thrown(error)
if (child !== undefined && child.pid <= 0) {
let closeError: Error | undefined
try {
@@ -335,70 +461,112 @@ export async function startClaudeCodeRun(
closeError = thrown(disposeError)
}
let spawnError = startupError
let spawnError = thrown(error)
try {
await child.done
} catch (childError: unknown) {
spawnError = thrown(childError)
}
const cancelled = cancelledBeforeCleanup || isAborted(request.signal)
if (closeError !== undefined) {
const failures = cancelled
? [
new Error('subagent-claude-code: request was aborted before SDK startup'),
spawnError,
closeError,
]
: [spawnError, closeError]
throw new AggregateError(
failures,
cancelled
? `subagent-claude-code: request was aborted before SDK startup; Claude Code process startup also failed: ${spawnError.message}; query cleanup also failed`
: `subagent-claude-code: Claude Code process startup failed: ${spawnError.message}; query cleanup also failed`,
const failure = startupFailure(spawnError)
const cleanupFailure = new ClaudeCodeFailure({
stage: 'teardown',
category: 'unknown',
}, closeError)
const aggregate = new AggregateError(
[failure, cleanupFailure],
`${failure.message}; ${cleanupFailure.message}`,
)
reportFailure(aggregate)
throw aggregate
}
if (cancelled) {
if (cancelledBeforeCleanup || isAborted(request.signal)) {
throw new Error('subagent-claude-code: request was aborted before SDK startup')
}
throw spawnError
const failure = startupFailure(spawnError)
reportFailure(failure)
throw failure
}
if (child !== undefined) {
try {
await disposeClaudeCodeChild(query, child)
} catch (disposeError: unknown) {
throw new AggregateError(
[startupError, thrown(disposeError)],
'subagent-claude-code: startup failed and CLI cleanup also failed',
const failure = startupFailure()
const cleanupFailure = thrown(disposeError)
const aggregate = new AggregateError(
[failure, cleanupFailure],
`${failure.message}; ${cleanupFailure.message}`,
)
reportFailure(aggregate)
throw aggregate
}
} else if (query !== undefined) {
try {
query.close()
} catch (disposeError: unknown) {
throw new AggregateError(
[startupError, thrown(disposeError)],
'subagent-claude-code: startup failed and query cleanup also failed',
const failure = startupFailure()
const cleanupFailure = new ClaudeCodeFailure({
stage: 'teardown',
category: 'unknown',
}, thrown(disposeError))
const aggregate = new AggregateError(
[failure, cleanupFailure],
`${failure.message}; ${cleanupFailure.message}`,
)
reportFailure(aggregate)
throw aggregate
}
}
if (cancelledBeforeCleanup || isAborted(request.signal)) {
throw new Error('subagent-claude-code: request was aborted before SDK startup')
}
throw startupError
const failure = startupFailure()
reportFailure(failure)
throw failure
}
const publishedQuery = query
const publishedChild = child
let receivedResult = false
const result = settleRunResult({
attempt: () => consumeClaudeQuery(publishedQuery, () => {
captureDiagnostic(unattendedDiagnostic(
spec.permissionMode,
'tool permission',
'denied',
'Claude Code denied the request before an interactive prompt',
))
}),
attempt: async () => {
try {
return await consumeClaudeQuery(publishedQuery, () => {
capturePermissionDiagnostic(unattendedDiagnostic(
spec.permissionMode,
'tool permission',
'denied',
'Claude Code denied the request before an interactive prompt',
))
}, () => {
receivedResult = true
})
} catch (error: unknown) {
const processOutcome = managedProcess?.outcome
let facts: ClaudeCodeFailureFacts
if (error instanceof ClaudeCodeFailure) {
facts = { ...error.facts, outcome: processOutcome }
} else if (processOutcome !== undefined && !receivedResult) {
facts = {
stage: 'process',
category: 'process-exit',
outcome: processOutcome,
}
} else {
facts = {
stage: 'query-run',
category: 'unknown',
outcome: processOutcome,
}
}
prependFailureDiagnostic(facts)
// Keep the SDK category and cause; the diagnostic adds later process facts.
throw error instanceof ClaudeCodeFailure
? error
: new ClaudeCodeFailure(facts, thrown(error))
}
},
collectOutput: () => [],
collectDiagnostic: () => diagnostic,
cancelled: () => controller.signal.aborted,
@@ -413,9 +581,14 @@ export async function startClaudeCodeRun(
signal: request.signal,
onAbort,
requestCancel,
teardown: () => disposeClaudeCodeChild(
publishedQuery,
publishedChild,
),
teardown: async () => {
try {
await disposeClaudeCodeChild(publishedQuery, publishedChild)
} catch (error: unknown) {
const failure = thrown(error)
reportFailure(failure)
throw failure
}
},
})
}
@@ -21,7 +21,11 @@ import { Context } from '@deepseek-ai/cordis'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import type {
SubprocessHandle,
SubprocessOutcome,
SubprocessSpawnSpec,
} from '@deepseek-ai/dsh-subprocess'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import * as claudeCode from '../src/index.ts'
import type { ClaudeCodePermissionMode } from '../src/run.ts'
@@ -32,6 +36,7 @@ import {
} from './messages-fixture.ts'
const observedSdkMessages = vi.hoisted((): SDKMessage[] => [])
const sdkTestOverrides = vi.hoisted((): { maxTurns?: number } => ({}))
vi.mock('@anthropic-ai/claude-agent-sdk', async (importOriginal) => {
const actual = await importOriginal<
@@ -39,8 +44,13 @@ vi.mock('@anthropic-ai/claude-agent-sdk', async (importOriginal) => {
>()
return {
...actual,
query(options: Parameters<typeof actual.query>[0]): Query {
const query = actual.query(options)
query(params: Parameters<typeof actual.query>[0]): Query {
const query = actual.query(sdkTestOverrides.maxTurns === undefined
? params
: {
...params,
options: { ...params.options, maxTurns: sdkTestOverrides.maxTurns },
})
// Observe the real SDK stream without replacing its protocol or CLI.
return new Proxy(query, {
get(target, property) {
@@ -112,6 +122,7 @@ afterEach(async () => {
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
}
observedSdkMessages.length = 0
delete sdkTestOverrides.maxTurns
})
interface RealHarness {
@@ -238,6 +249,29 @@ async function expectQuiescent(
}
}
function expectedFailure(
stage: 'query-run' | 'process',
category: 'error_during_execution' | 'process-exit',
outcome: SubprocessOutcome,
): string {
const fields = [
'product: Claude Code',
`stage: ${stage}`,
`category: ${category}`,
]
if (outcome.exitCode !== null) fields.push(`exit code: ${outcome.exitCode}`)
if (outcome.signal !== null) fields.push(`signal: ${outcome.signal}`)
return `Product subagent failure (${fields.join('; ')})`
}
function expectedObservedFailure(outcome: SubprocessOutcome): string {
return observedSdkMessages.some(message =>
message.type === 'result'
&& message.subtype === 'error_during_execution')
? expectedFailure('query-run', 'error_during_execution', outcome)
: expectedFailure('process', 'process-exit', outcome)
}
function startRequest(
harness: RealHarness,
prompt: string,
@@ -288,9 +322,6 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220
.toBe(process.platform === 'win32'
? realpathSync(claudeBin).toLowerCase()
: realpathSync(claudeBin))
expect(harness.spawnSpecs[0]?.env)
.not.toHaveProperty('DSH_CLAUDE_CODE_EXECUTABLE')
expect(fixture.requests).toHaveLength(1)
const recorded = fixture.requests[0]!
expect(recorded.method).toBe('POST')
@@ -314,6 +345,39 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220
await expectQuiescent(harness.handles)
})
it('maps a real SDK max-turns result to safe query-run facts', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-claude-code-max-turns-'))
roots.push(root)
const target = join(root, 'max-turns.txt')
sdkTestOverrides.maxTurns = 1
const { harness, fixture } = await realHarness({
kind: 'tool-use',
toolName: 'Write',
input: {
file_path: target,
content: 'real-sdk-max-turns',
},
}, 'bypassPermissions')
const run = await startRequest(harness, 'Exercise the SDK max-turns result.')
const result = await run.result
expect(observedSdkMessages
.filter(message => message.type === 'result')
.map(message => message.subtype)).toEqual(['error_max_turns'])
expect(result).toMatchObject({
output: [],
stopReason: 'error',
})
expect(result.diagnostic).toContain(
'product: Claude Code; stage: query-run; category: error_max_turns',
)
expect(readFileSync(target, 'utf8')).toBe('real-sdk-max-turns')
expect(result.diagnostic).not.toContain(target)
expect(result.diagnostic).not.toContain('real-sdk-max-turns')
await run.dispose()
expect(fixture.requests).toHaveLength(1)
await expectQuiescent(harness.handles)
})
it('runs two named instances concurrently and unloads one without revoking its run', async () => {
const safeInstance = await realInstanceFixture({ kind: 'hold' })
const bypassInstance = await realInstanceFixture({
@@ -388,16 +452,17 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220
expect(ctx.subagents.list()).toEqual([])
})
it('maps a real CLI process failure to error', async () => {
it('maps a real CLI process failure to its exit outcome', async () => {
const { harness, fixture } = await realHarness({ kind: 'hold' })
const run = await startRequest(harness, 'Exercise the failure path.')
await fixture.requestStarted
expect(harness.handles).toHaveLength(1)
harness.handles[0]!.terminate()
await expect(run.result).resolves.toEqual({
output: [],
stopReason: 'error',
})
const outcome = await harness.handles[0]!.done
const result = await run.result
expect(result.output).toEqual([])
expect(result.stopReason).toBe('error')
expect(result.diagnostic).toBe(expectedObservedFailure(outcome))
await run.dispose()
expect(fixture.requests).toHaveLength(1)
expect(fixture.requests[0]!.headers['x-api-key']).toBe(fakeKey)
@@ -424,12 +489,15 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220
}, { timeout: 30_000 })
expect(existsSync(target)).toBe(false)
harness.handles[0]!.terminate()
const outcome = await harness.handles[0]!.done
const result = await run.result
expect(result).toEqual({
output: [],
diagnostic: 'Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt',
stopReason: 'error',
})
expect(result.output).toEqual([])
expect(result.stopReason).toBe('error')
const diagnosticLines = result.diagnostic?.split('\n') ?? []
expect(diagnosticLines[0]).toBe(expectedObservedFailure(outcome))
expect(diagnosticLines[1]).toBe(
'Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt',
)
expect(result.diagnostic).not.toContain(target)
expect(result.diagnostic).not.toContain('SECRET_TOKEN')
await run.dispose()
@@ -93,6 +93,12 @@ async function nextTask(): Promise<void> {
await new Promise<void>((resolve) => { setImmediate(resolve) })
}
function errorCause(value: unknown): Error | undefined {
return value instanceof Error && value.cause instanceof Error
? value.cause
: undefined
}
interface FakeChildOptions {
readonly pid?: number
readonly exitOnTerminate?: boolean
@@ -209,6 +215,25 @@ function failure(
} as SDKResultMessage
}
function expectedFailureDiagnostic(
stage: 'query-start' | 'query-run' | 'process' | 'teardown',
category: string,
outcome?: Partial<SubprocessOutcome>,
): string {
const fields = [
'product: Claude Code',
`stage: ${stage}`,
`category: ${category}`,
]
if (outcome?.exitCode !== null && outcome?.exitCode !== undefined) {
fields.push(`exit code: ${outcome.exitCode}`)
}
if (outcome?.signal !== null && outcome?.signal !== undefined) {
fields.push(`signal: ${outcome.signal}`)
}
return `Product subagent failure (${fields.join('; ')})`
}
function permissionDenied(): SDKPermissionDeniedMessage {
return {
type: 'system',
@@ -426,8 +451,6 @@ describe('task admission and package contracts', () => {
const safeChild = fakeChild()
const bypassChild = fakeChild()
const spawnSpecs: SubprocessSpawnSpec[] = []
vi.spyOn(ctx.subprocess, 'resolveExecutable')
.mockResolvedValue('/native/claude')
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => {
spawnSpecs.push(spec)
return spec.env?.DSH_CLAUDE_INSTANCE === 'safe'
@@ -438,7 +461,6 @@ describe('task admission and package contracts', () => {
queryMock.mockImplementation(({ options }) => {
queryOptions.push(options)
options.spawnClaudeCodeProcess!(sdkSpawnOptions({
command: options.pathToClaudeCodeExecutable!,
cwd: options.cwd!,
env: options.env!,
signal: options.abortController!.signal,
@@ -591,14 +613,51 @@ describe('task admission and package contracts', () => {
)
expect(queryMock).not.toHaveBeenCalled()
const invalidCwdParent = {
id: 'parent-with-invalid-cwd',
session: { header: { cwd: 'relative/SECRET_TOKEN' } },
} as unknown as Agent
const invalidCwd = ctx.subagents.start('claude-diagnostic', {
...request(),
parent: invalidCwdParent,
})
await expect(invalidCwd)
.rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown'))
await expect(invalidCwd).rejects.not.toThrow('relative/SECRET_TOKEN')
expect(warn).toHaveBeenCalledWith(
'subagent-claude-code "claude-diagnostic": child start failed: %o',
expect.any(Error),
)
expect(errorCause(warn.mock.calls[0]?.[1] as unknown)?.message)
.toContain('relative/SECRET_TOKEN')
const invalidCwdAbort = new AbortController()
invalidCwdAbort.abort(new Error('cancel invalid cwd startup'))
await expect(ctx.subagents.start('claude-diagnostic', {
...request(undefined, invalidCwdAbort.signal),
parent: invalidCwdParent,
})).rejects.toThrow('aborted before SDK startup')
expect(queryMock).not.toHaveBeenCalled()
warn.mockClear()
vi.stubEnv('PATH', '/host/bin')
queryMock.mockImplementationOnce(() => {
throw new Error(
'Native CLI binary for fixture-platform not found. Reinstall @anthropic-ai/claude-agent-sdk without --omit=optional, or set options.pathToClaudeCodeExecutable.',
)
})
await expect(ctx.subagents.start('claude-diagnostic', request()))
.rejects.toThrow('Native CLI binary for fixture-platform not found')
const missingPayload = ctx.subagents.start('claude-diagnostic', request())
await expect(missingPayload)
.rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown'))
await expect(missingPayload).rejects.not.toThrow('Native CLI binary')
expect(warn).toHaveBeenCalledWith(
expect.stringContaining(
'subagent-claude-code "claude-diagnostic": child run failed (error):',
),
expect.any(Error),
)
expect(errorCause(warn.mock.calls[0]?.[1] as unknown)?.message)
.toContain('Native CLI binary for fixture-platform not found')
expect(resolveExecutable).not.toHaveBeenCalled()
const run = await ctx.subagents.start('claude-diagnostic', request())
@@ -606,11 +665,15 @@ describe('task admission and package contracts', () => {
child.stdout.end()
await expect(run.result).resolves.toEqual({
output: [],
diagnostic: expectedFailureDiagnostic('query-run', 'missing-result'),
stopReason: 'error',
})
expect(warn).toHaveBeenCalledWith(expect.stringContaining(
'subagent-claude-code "claude-diagnostic": child run failed (error):',
))
expect(warn).toHaveBeenCalledWith(
expect.stringContaining(
'subagent-claude-code "claude-diagnostic": child run failed (error):',
),
expect.any(Error),
)
expect(resolveExecutable).not.toHaveBeenCalled()
expect(queryMock.mock.calls[1]?.[0].options)
.not.toHaveProperty('pathToClaudeCodeExecutable')
@@ -704,7 +767,6 @@ describe('official spawn projection', () => {
expect(spec.argv).toEqual([
command, '--output-format', 'stream-json',
])
expect(spec.env).not.toHaveProperty('DSH_CLAUDE_CODE_EXECUTABLE')
})
it('projects streams, exit facts, listeners, and idempotent tree termination', async () => {
@@ -715,6 +777,7 @@ describe('official spawn projection', () => {
expect(process.killed).toBe(false)
expect(process.exitCode).toBeNull()
expect(process.signalCode).toBeNull()
expect(process.outcome).toBeUndefined()
const exit = vi.fn()
const once = vi.fn()
@@ -734,11 +797,12 @@ describe('official spawn projection', () => {
expect(once).toHaveBeenCalledOnce()
expect(removed).not.toHaveBeenCalled()
expect(process.signalCode).toBe('SIGTERM')
expect(process.outcome).toEqual({ exitCode: null, signal: 'SIGTERM' })
expect(process.kill('SIGTERM')).toBe(false)
})
it('emits spawn errors', async () => {
const child = fakeChild()
const child = fakeChild({ pid: -1 })
const process = new ManagedClaudeCodeProcess(child.handle)
const errorListener = vi.fn()
const removed = vi.fn()
@@ -760,6 +824,7 @@ describe('official spawn projection', () => {
await nextTask()
expect(process.exitCode).toBe(7)
expect(process.signalCode).toBeNull()
expect(process.outcome).toEqual({ exitCode: 7, signal: null })
expect(process.kill('SIGTERM')).toBe(false)
})
})
@@ -902,17 +967,34 @@ describe('query options and result mapping', () => {
it('accepts only a non-error success with a non-blank final result', () => {
expect(successfulResult(success('exact final'))).toBe('exact final')
expect(() => successfulResult(success('answer', true)))
.toThrow('marked as an error')
.toThrow(expectedFailureDiagnostic('query-run', 'invalid-success'))
expect(() => successfulResult(success(' \n ')))
.toThrow('contained no answer')
expect(() => successfulResult(failure(
.toThrow(expectedFailureDiagnostic('query-run', 'invalid-success'))
const sdkFailure = () => successfulResult(failure(
'error_during_execution',
['first', 'second'],
))).toThrow('first; second')
['SECRET_TOKEN', '/private/secret.txt'],
))
expect(sdkFailure).toThrow(expectedFailureDiagnostic(
'query-run',
'error_during_execution',
))
expect(sdkFailure).not.toThrow('SECRET_TOKEN')
expect(sdkFailure).not.toThrow('/private/secret.txt')
expect(() => successfulResult(failure(
'error_max_turns',
[],
))).toThrow('error_max_turns')
))).toThrow(expectedFailureDiagnostic('query-run', 'error_max_turns'))
const unknown = {
type: 'result',
subtype: 'future_failure',
is_error: true,
errors: ['SECRET_TOKEN'],
} as unknown as SDKResultMessage
expect(() => successfulResult(unknown))
.toThrow(expectedFailureDiagnostic('query-run', 'unknown'))
expect(() => successfulResult(unknown)).not.toThrow('future_failure')
expect(() => successfulResult(unknown)).not.toThrow('SECRET_TOKEN')
})
it('consumes the complete stream and keeps the latest strict success', async () => {
@@ -927,7 +1009,7 @@ describe('query options and result mapping', () => {
})
await expect(consumeClaudeQuery(
queryFrom([{ type: 'system', subtype: 'init' } as SDKMessage]),
)).rejects.toThrow('ended without a result')
)).rejects.toThrow(expectedFailureDiagnostic('query-run', 'missing-result'))
const onPermissionDenied = vi.fn()
await expect(consumeClaudeQuery(queryFrom([
@@ -981,6 +1063,7 @@ describe('run publication, cancellation, and settlement', () => {
)
await expect(run.result).resolves.toEqual({
output: [],
diagnostic: expectedFailureDiagnostic('query-run', subtype),
stopReason: 'error',
})
expect(onError).toHaveBeenCalledWith(
@@ -1000,7 +1083,7 @@ describe('run publication, cancellation, and settlement', () => {
const result = await run.result
expect(result).toEqual({
output: [],
diagnostic: 'Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt',
diagnostic: `${expectedFailureDiagnostic('query-run', 'error_during_execution')}\nClaude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt`,
stopReason: 'error',
})
expect(result.diagnostic).not.toContain('SECRET_TOKEN')
@@ -1041,39 +1124,99 @@ describe('run publication, cancellation, and settlement', () => {
})
await expect(failed.result).resolves.toEqual({
output: [],
diagnostic: expectedFailureDiagnostic(
'query-run',
'error_during_execution',
),
stopReason: 'error',
})
await Promise.all([completed.dispose(), failed.dispose()])
})
it('fails closed when iteration rejects after a result', async () => {
const fixture = fakeRun(
[success('partial final')],
new Error('iterator boom'),
)
const run = await startClaudeCodeRun(request(), fixture.spec)
const child = fakeChild()
const outcome = { exitCode: 31, signal: null } as const
async function* stream(): AsyncGenerator<SDKMessage, void> {
yield success('partial final')
child.settle(outcome)
await Promise.resolve()
throw new Error('iterator boom')
}
queryMock.mockImplementation(({ options }) => {
options.spawnClaudeCodeProcess!(sdkSpawnOptions())
return Object.assign(stream(), { close: vi.fn() }) as unknown as Query
})
const run = await startClaudeCodeRun(request(), {
cwd: '/workspace',
permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE,
env: {},
disposeGraceMs: 5,
spawn: () => child.handle,
})
await expect(run.result).resolves.toEqual({
output: [],
diagnostic: expectedFailureDiagnostic('query-run', 'unknown', outcome),
stopReason: 'error',
})
await run.dispose()
})
it('maps invalid success and missing result to error', async () => {
for (const messages of [
[success('answer', true)],
[success('')],
[{ type: 'system', subtype: 'init' } as SDKMessage],
]) {
it('maps invalid success and missing result to fixed query-run facts', async () => {
for (const [messages, category] of [
[[success('answer', true)], 'invalid-success'],
[[success('')], 'invalid-success'],
[[{ type: 'system', subtype: 'init' } as SDKMessage], 'missing-result'],
] as const) {
const fixture = fakeRun(messages)
const run = await startClaudeCodeRun(request(), fixture.spec)
await expect(run.result).resolves.toMatchObject({
await expect(run.result).resolves.toEqual({
output: [],
diagnostic: expectedFailureDiagnostic('query-run', category),
stopReason: 'error',
})
await run.dispose()
}
})
it('reports an early process exit with independent code and signal facts', async () => {
const outcomes: SubprocessOutcome[] = [
{ exitCode: 23, signal: null },
{ exitCode: null, signal: 'SIGABRT' },
{ exitCode: null, signal: null },
]
for (const outcome of outcomes) {
const child = fakeChild()
async function* stream(): AsyncGenerator<SDKMessage, void> {
child.settle(outcome)
await Promise.resolve()
throw new Error('SECRET_TOKEN from process transport')
}
queryMock.mockImplementation(({ options }) => {
options.spawnClaudeCodeProcess!(sdkSpawnOptions())
return Object.assign(stream(), { close: vi.fn() }) as unknown as Query
})
const run = await startClaudeCodeRun(request(), {
cwd: '/workspace',
permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE,
env: {},
disposeGraceMs: 5,
spawn: () => child.handle,
})
const result = await run.result
expect(result).toEqual({
output: [],
diagnostic: expectedFailureDiagnostic(
'process',
'process-exit',
outcome,
),
stopReason: 'error',
})
expect(result.diagnostic).not.toContain('SECRET_TOKEN')
await run.dispose()
}
})
it('gives local cancellation precedence and isolates overlapping controllers', async () => {
const firstChild = fakeChild()
const secondChild = fakeChild()
@@ -1162,7 +1305,7 @@ describe('run publication, cancellation, and settlement', () => {
)
await expect(startClaudeCodeRun(request(), {
...unused.spec,
})).rejects.toThrow('did not publish a controllable')
})).rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown'))
expect(noChildClose).toHaveBeenCalledOnce()
const closeFailure = vi.fn(() => { throw new Error('close boom') })
@@ -1172,6 +1315,11 @@ describe('run publication, cancellation, and settlement', () => {
const noChild = startClaudeCodeRun(request(), {
...unused.spec,
})
await expect(noChild)
.rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown'))
await expect(noChild).rejects.toThrow(
`${expectedFailureDiagnostic('query-start', 'unknown')}; subagent-claude-code: ${expectedFailureDiagnostic('teardown', 'unknown')}`,
)
await expect(noChild).rejects.toBeInstanceOf(AggregateError)
const startupAbort = new AbortController()
@@ -1194,12 +1342,53 @@ describe('run publication, cancellation, and settlement', () => {
expect(abortedClose).toHaveBeenCalledOnce()
expect(abortedChild.terminate).toHaveBeenCalledOnce()
const cleanupAbort = new AbortController()
const cleanupFailedChild = fakeChild({
waitForExitError: new Error('SECRET_TOKEN cleanup wait failure'),
})
queryMock.mockImplementationOnce(({ options }) => {
options.spawnClaudeCodeProcess!(sdkSpawnOptions())
cleanupAbort.abort(new Error('startup cancelled'))
return queryFrom([])
})
const cancelledCleanupFailure = startClaudeCodeRun(
request(undefined, cleanupAbort.signal),
{
...unused.spec,
spawn: () => cleanupFailedChild.handle,
},
)
await expect(cancelledCleanupFailure)
.rejects.toBeInstanceOf(AggregateError)
await expect(cancelledCleanupFailure)
.rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown'))
await expect(cancelledCleanupFailure).rejects.toThrow(
`${expectedFailureDiagnostic('query-start', 'unknown')}; subagent-claude-code: ${expectedFailureDiagnostic('teardown', 'unknown', { exitCode: 0, signal: null })}`,
)
await expect(cancelledCleanupFailure)
.rejects.not.toThrow('SECRET_TOKEN')
queryMock.mockImplementationOnce(() => {
throw new Error('query failed before resource creation')
})
await expect(startClaudeCodeRun(request(), {
const queryFailureOnError = vi.fn<
NonNullable<ClaudeCodeRunSpec['onError']>
>()
const queryFailure = startClaudeCodeRun(request(), {
...unused.spec,
})).rejects.toThrow('query failed before resource creation')
onError: queryFailureOnError,
})
await expect(queryFailure)
.rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown'))
await expect(queryFailure).rejects.not.toThrow(
'query failed before resource creation',
)
expect(queryFailureOnError).toHaveBeenCalledWith(
expect.any(Error),
'error',
)
expect(errorCause(queryFailureOnError.mock.calls[0]?.[0])?.message)
.toBe('query failed before resource creation')
const spawned = fakeChild()
const spawnSpecs: SubprocessSpawnSpec[] = []
@@ -1207,6 +1396,7 @@ describe('run publication, cancellation, and settlement', () => {
queryMock.mockImplementationOnce(({ options }) => {
factoryController = options.abortController
options.spawnClaudeCodeProcess!(sdkSpawnOptions())
spawned.settle({ exitCode: 17, signal: null })
throw new Error('query construction failed')
})
const factoryFailure = startClaudeCodeRun(request(), {
@@ -1216,11 +1406,34 @@ describe('run publication, cancellation, and settlement', () => {
return spawned.handle
},
})
await expect(factoryFailure).rejects.toThrow('query construction failed')
await expect(factoryFailure).rejects.toThrow(expectedFailureDiagnostic(
'query-start',
'unknown',
{ exitCode: 17, signal: null },
))
await expect(factoryFailure).rejects.not.toThrow('query construction failed')
expect(spawnSpecs).toHaveLength(1)
expect(factoryController?.signal.aborted).toBe(true)
expect(spawned.terminate).toHaveBeenCalledOnce()
const cleanupRaceAbort = new AbortController()
const cleanupRaceChild = fakeChild({ exitOnTerminate: false })
queryMock.mockImplementationOnce(({ options }) => {
options.spawnClaudeCodeProcess!(sdkSpawnOptions())
throw new Error('query failed before cleanup wait')
})
const cleanupRace = startClaudeCodeRun(
request(undefined, cleanupRaceAbort.signal),
{
...unused.spec,
spawn: () => cleanupRaceChild.handle,
},
)
await nextTask()
cleanupRaceAbort.abort(new Error('cancelled during cleanup'))
cleanupRaceChild.settle()
await expect(cleanupRace).rejects.toThrow('aborted before SDK startup')
const spawnError = Object.assign(
new Error('spawn /sdk/claude EACCES'),
{ code: 'EACCES', path: '/sdk/claude' },
@@ -1230,8 +1443,11 @@ describe('run publication, cancellation, and settlement', () => {
doneError: spawnError,
})
const failed = fakeRun([], undefined, failedSpawn)
await expect(startClaudeCodeRun(request(), failed.spec))
.rejects.toBe(spawnError)
const failedStartup = startClaudeCodeRun(request(), failed.spec)
await expect(failedStartup)
.rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown'))
await expect(failedStartup).rejects.not.toThrow('spawn /sdk/claude EACCES')
await expect(failedStartup).rejects.toMatchObject({ cause: spawnError })
expect(failed.close).toHaveBeenCalledOnce()
expect(failedSpawn.terminate).not.toHaveBeenCalled()
expect(failedSpawn.waitForExit).not.toHaveBeenCalled()
@@ -1272,13 +1488,20 @@ describe('run publication, cancellation, and settlement', () => {
{ ...unused.spec, spawn: () => cancelledFailedSpawnWithCloseFailure.handle },
)
await expect(cancelledWithCloseFailure).rejects.toMatchObject({
message: 'subagent-claude-code: request was aborted before SDK startup; Claude Code process startup also failed: spawn /sdk/claude EACCES; query cleanup also failed',
message: `subagent-claude-code: ${expectedFailureDiagnostic('query-start', 'unknown')}; subagent-claude-code: ${expectedFailureDiagnostic('teardown', 'unknown')}`,
errors: [
expect.objectContaining({ message: 'subagent-claude-code: request was aborted before SDK startup' }),
spawnError,
cancelledFailedSpawnCloseError,
expect.objectContaining({
message: `subagent-claude-code: ${expectedFailureDiagnostic('query-start', 'unknown')}`,
cause: spawnError,
}),
expect.objectContaining({
message: `subagent-claude-code: ${expectedFailureDiagnostic('teardown', 'unknown')}`,
cause: cancelledFailedSpawnCloseError,
}),
],
})
await expect(cancelledWithCloseFailure)
.rejects.not.toThrow('spawn /sdk/claude EACCES')
expect(cancelledFailedSpawnClose).toHaveBeenCalledOnce()
const failedSpawnCloseError = new Error('query close failed')
@@ -1296,26 +1519,41 @@ describe('run publication, cancellation, and settlement', () => {
spawn: () => failedSpawnWithCloseFailure.handle,
})
await expect(failedWithCloseFailure)
.rejects.toThrow('spawn /sdk/claude EACCES')
.rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown'))
await expect(failedWithCloseFailure)
.rejects.not.toThrow('spawn /sdk/claude EACCES')
await expect(failedWithCloseFailure).rejects.toMatchObject({
errors: [spawnError, failedSpawnCloseError],
message: `subagent-claude-code: ${expectedFailureDiagnostic('query-start', 'unknown')}; subagent-claude-code: ${expectedFailureDiagnostic('teardown', 'unknown')}`,
errors: [
expect.objectContaining({ cause: spawnError }),
expect.objectContaining({ cause: failedSpawnCloseError }),
],
})
const cleanupError = new Error('live child cleanup failed')
const constructionError = new Error(
'query construction failed with a live child',
)
const liveChildCleanupFailure = fakeChild({ doneError: cleanupError })
const liveChildCleanupFailure = fakeChild({ waitForExitError: cleanupError })
queryMock.mockImplementationOnce(({ options }) => {
options.spawnClaudeCodeProcess!(sdkSpawnOptions())
throw constructionError
})
await expect(startClaudeCodeRun(request(), {
const liveCleanupFailure = startClaudeCodeRun(request(), {
...unused.spec,
spawn: () => liveChildCleanupFailure.handle,
})).rejects.toMatchObject({
errors: [constructionError, cleanupError],
})
await expect(liveCleanupFailure).rejects.toMatchObject({
message: `subagent-claude-code: ${expectedFailureDiagnostic('query-start', 'unknown')}; subagent-claude-code: ${expectedFailureDiagnostic('teardown', 'unknown', { exitCode: 0, signal: null })}`,
errors: [
expect.objectContaining({ cause: constructionError }),
expect.objectContaining({ cause: cleanupError }),
],
})
await expect(liveCleanupFailure)
.rejects.not.toThrow('query construction failed with a live child')
await expect(liveCleanupFailure)
.rejects.not.toThrow('live child cleanup failed')
})
})
@@ -1334,6 +1572,28 @@ describe('query and process disposal', () => {
})
})
it('reports a published teardown failure to the Host diagnostic sink', async () => {
const fixture = fakeRun([success('exact answer')])
const onError = vi.fn<NonNullable<ClaudeCodeRunSpec['onError']>>()
const run = await startClaudeCodeRun(request(), {
...fixture.spec,
onError,
})
await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
fixture.close.mockImplementationOnce(() => {
throw new Error('SECRET_TOKEN close failure')
})
await expect(run.dispose()).rejects.toThrow(
expectedFailureDiagnostic('teardown', 'unknown', {
exitCode: 0,
signal: null,
}),
)
expect(onError).toHaveBeenCalledWith(expect.any(Error), 'error')
expect(errorCause(onError.mock.calls[0]?.[0])?.message)
.toBe('SECRET_TOKEN close failure')
})
it('does not finish disposal before the managed tree exits', async () => {
const child = fakeChild({ exitOnTerminate: false })
let disposed = false
@@ -1350,33 +1610,30 @@ describe('query and process disposal', () => {
expect(disposed).toBe(true)
})
it('reports wait, close, and direct-child failures without skipping cleanup', async () => {
it('reports close and tree-wait failures without skipping cleanup', async () => {
const waitFailure = fakeChild({
waitForExitError: new Error('wait boom'),
})
const closeFailure = vi.fn(() => { throw new Error('close boom') })
await expect(disposeClaudeCodeChild(
const waitAndClose = disposeClaudeCodeChild(
{ close: closeFailure },
waitFailure.handle,
)).rejects.toBeInstanceOf(AggregateError)
)
await expect(waitAndClose).rejects.toThrow(expectedFailureDiagnostic(
'teardown',
'unknown',
{ exitCode: 0, signal: null },
))
const waitAndCloseError = await waitAndClose.then(
() => undefined,
(error: unknown) => error,
)
const waitAndCloseCause = errorCause(waitAndCloseError)
expect(waitAndCloseCause).toBeInstanceOf(AggregateError)
expect((waitAndCloseCause as AggregateError).errors).toEqual([
expect.objectContaining({ message: 'close boom' }),
expect.objectContaining({ message: 'wait boom' }),
])
expect(waitFailure.terminate).toHaveBeenCalledOnce()
const doneFailure = fakeChild({
pid: -1,
doneError: new Error('spawn boom'),
})
await expect(disposeClaudeCodeChild(
{ close: vi.fn() },
doneFailure.handle,
)).rejects.toThrow('spawn boom')
const both = fakeChild({
pid: -1,
doneError: new Error('spawn boom'),
})
await expect(disposeClaudeCodeChild(
{ close: () => { throw new Error('close boom') } },
both.handle,
)).rejects.toBeInstanceOf(AggregateError)
})
})
@@ -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/subagent/subagent-codex/README.md
README.md: 1b9f777c26fa2e1ee050dfc73bdb648e66e93043
README.zh.md: f70510dd680a78193ebb45e95b9e666de59db571
README.md: 975f353b9f1bc6fab61a4c0eb40ebaf50c436623
README.zh.md: c51f35d9e419e621b5a2cc3101de6bd5e56aa145
+9 -9
View File
@@ -6,13 +6,15 @@ This package registers a Profile-named Codex subagent provider whose default nam
## Start and ownership
`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It then spawns the fixed command through [`dsh-subprocess`](../../subprocess/subprocess/README.md), performs `initialize``initialized`, maps the Profile-selected mode into official `thread/start` approval/reviewer/sandbox fields beside `{ cwd, ephemeral: true }`, and publishes the run only after Codex returns a valid ephemeral thread. A failure or cancellation before publication closes the wire, terminates the managed process tree, waits for it to exit, and rejects `start()`.
`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It then spawns the fixed command through [`dsh-subprocess`](../../subprocess/subprocess/README.md), performs `initialize``initialized`, maps the Profile-selected mode into official `thread/start` approval/reviewer/sandbox fields beside `{ cwd, ephemeral: true }`, and publishes the run only after Codex returns a valid ephemeral thread. A failure or cancellation before publication closes the wire, terminates the managed process tree, waits for it to exit, and rejects `start()`. Non-cancellation rejections expose only the fixed `initialize` or `thread-start` stage plus an already observed process outcome; raw product and Host errors remain on internal cause chains.
The published `run.result` starts exactly one turn. It accepts only notifications for that run's thread and turn, then waits for the authoritative `turn/completed` terminal notification. The latest `agentMessage` with `phase: "final_answer"` wins; when Codex emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback. Commentary never replaces either answer, and a successful turn with no nonblank answer settles as an error.
For command and file approvals, the unattended provider selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It answers permission requests with an empty turn-scoped permission set, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run. The wire records only the effective mode, request category, decision, and fixed safe reason. It also recognizes declined command/file items and `sandboxError` terminals. Codex 0.147.0 writes some early `never` rejections and sandbox violations only to structured stderr, so the Provider pipes stderr, forwards it unchanged to the host, and matches two fixed signatures in a bounded per-run tail; raw stderr never enters the diagnostic.
Local cancellation wins the result race and maps to `aborted`. A failed turn whose `codexErrorInfo` is `contextWindowExceeded` maps to `max-tokens`; every other remote interrupted or failed turn maps to `error`, and the provider produces no `refusal`. A permission-related error may additionally carry the bounded, non-assistant `SubagentResult.diagnostic`; successful and locally cancelled runs omit it. `dispose()` is idempotent: it requests a best-effort `turn/interrupt` with both current ids when they are known, closes the JSON-RPC wire, ends stdin, invokes the shared process-tree termination escalation, waits for whole-tree exit, and detaches the stderr observer. Result failure and independent teardown failure remain separate.
Local cancellation wins the result race and maps to `aborted`. For failed turns, the diagnostic preserves all eleven string and five object variants in the Codex 0.147.0 `codexErrorInfo` union; the four connection/stream variants retain a numeric `httpStatusCode` when supplied, while `activeTurnNotSteerable` does not expose `turnKind`. The diagnostic also names `turn-start`, `turn`, or `process`, independently includes available exit code and signal, and uses `unknown` for unrecognized or malformed values without copying raw fields. `contextWindowExceeded` remains `max-tokens`; every other remote interruption or failure remains `error`, and the provider produces no `refusal`. A contributing permission decision follows the structured failure line. Successful and locally cancelled runs omit both facts.
`dispose()` is idempotent: it requests a best-effort `turn/interrupt` with both current ids when they are known, closes the JSON-RPC wire, ends stdin, invokes the shared process-tree termination escalation, waits for whole-tree exit, and detaches the stderr observer. Independent cleanup rejection uses the fixed `teardown` stage and any available process outcome. When startup and rollback both fail, the top-level aggregate message preserves both safe stage lines while the raw failures remain internal.
## Capabilities and context
@@ -92,13 +94,11 @@ The standalone composition below shows the complete explicit capability. A Profi
## Product compatibility and evidence
The production wire intentionally implements only the app-server methods required by this one-shot contract. The runtime dependency and all six optional-dependency aliases are pinned to `@openai/codex@0.147.0` / `codex-cli 0.147.0`. A normal install selects one payload for the current OS and CPU. For the current darwin-arm64 payload, `npm pack --dry-run --json @openai/codex@0.147.0-darwin-arm64` reports 111,199,052 packed bytes and 274,777,843 unpacked bytes. That package contains native `codex`, `codex-code-mode-host`, `rg`, and `zsh` resources; other platforms may differ, and these values are disclosure rather than an installation threshold. The keyless real-product test drives the package wrapper against a loopback Responses fixture, observes the package-local argv, and proves wrapper and native descendants become quiescent.
The production wire intentionally implements only the app-server methods required by this one-shot contract. The runtime dependency and all six optional-dependency aliases are pinned to `@openai/codex@0.147.0` / `codex-cli 0.147.0`. A normal install selects one payload for the current OS and CPU. For the current darwin-arm64 payload, `npm pack --dry-run --json @openai/codex@0.147.0-darwin-arm64` reports 111,199,052 packed bytes and 274,777,843 unpacked bytes. That package contains native `codex`, `codex-code-mode-host`, `rg`, and `zsh` resources; other platforms may differ, and these values are disclosure rather than an installation threshold.
Installing with optional dependencies omitted, using an unsupported platform, or losing the selected payload makes the first delegation fail with the wrapper's native-payload startup error. The provider neither probes a host CLI nor retries with one.
Generated schema evidence and package tests pin all sixteen error-info variants, HTTP-status locations, six lifecycle stages, process outcomes, stop-reason mapping, unknown fallback, sanitization, permission ordering, cancellation, concurrency, and cleanup aggregation. The keyless real-product test drives the package wrapper against a loopback Responses fixture and observes the package-local argv, exact Bearer key, original task, byte-exact final answer, thread-level `never` overriding ambient `on-request`, automatic-review startup, unattended rejection without file side effects, a real `internalServerError`, explicit dangerous-bypass writing in suite-owned temporary storage, process/protocol failure with safe exit facts, and wrapper/native quiescence. The same tier proves two named instances retain separate environments and native modes.
Real-product coverage additionally proves that thread-level `never` overrides an ambient `on-request`, automatic review starts through the official app-server, dangerous bypass writes only in suite-owned temporary storage, safe diagnostics exclude raw commands and paths, and every wrapper/native process exits.
The same real-product tier proves that two named instances retain separate environments and native modes.
Installing with optional dependencies omitted, using an unsupported platform, or losing the selected payload makes the first delegation fail at `initialize` with the safe `unknown` category and any observed process outcome. Raw wrapper text remains on Host stderr; the provider neither probes a host CLI nor retries with one. An isolated wrapper fixture separately proves the native payload failure and absence of host fallback.
## Model Experience
@@ -120,7 +120,7 @@ Independent of the parent request cache. Reuse depends only on Codex's own provi
#### What the model sees
Through `dsh-tool-subagent`, a foreground call gives the parent the selected final Codex answer or an error containing the stop reason and optional safe diagnostic for a non-completed result. A background call first returns a Job id; the generic job controls later deliver a completion notice, expose the final answer or failed status detail through `job_output`, and let `job_kill` request cancellation. Codex commentary, reasoning, tool activity, raw stderr, workspace diffs, usage, product ids, commands, paths, and protocol payloads are not copied into the parent Session.
Through `dsh-tool-subagent`, a foreground call gives the parent the selected final Codex answer or an error containing the stop reason and optional safe diagnostic for a non-completed result. The diagnostic can distinguish the fixed error-info category, protocol stage, numeric HTTP status, and observed process outcome without copying product prose. A background call first returns a Job id; the generic job controls later deliver a completion notice, expose the same final answer or failed status detail through `job_output`, and let `job_kill` request cancellation. Codex commentary, reasoning, tool activity, raw stderr, workspace diffs, usage, product ids, commands, paths, and protocol payloads are not copied into the parent Session.
#### Token effect
@@ -134,7 +134,7 @@ Append-only: foreground adds one result after the reusable parent prefix, while
- **One fresh process, thread, and turn per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence.
- **Static instance selection** — Profile rows fix provider names and tool bindings; calls cannot choose a provider dynamically, and every exposed tool needs a unique `toolName`.
- **Authentication and account state remain native** — the Bundle supplies the CLI but does not create an account, log in, trust a project, or rewrite Codex settings; configuration and authentication failures surface as startup or run errors.
- **Authentication and account state remain native** — the Bundle supplies the CLI but does not create an account, log in, trust a project, or rewrite Codex settings; configuration and authentication failures surface with their lifecycle stage and the safe `unknown` fallback rather than a separate public taxonomy.
- **The native platform payload is required at delegation time** — installs that omit optional dependencies, unsupported platforms, and missing or damaged payloads fail at the first run; there is no host-CLI fallback.
- **Compatibility is pinned by development evidence** — upgrading from the verified 0.147.0 protocol baseline requires regenerating upstream schema evidence and rerunning handshake, answer-selection, approval, cancellation, keyless real-product, and credentialed DeepSeek nonce tests.
- **No human approval path** — known unattended approval requests are denied and unknown server requests fail closed; the three Profile modes never create a DSH interaction channel or per-call allow policy.
@@ -6,13 +6,15 @@
## 启动与所有权
`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。随后,它通过 [`dsh-subprocess`](../../subprocess/subprocess/README.md) spawn 固定命令,依次执行 `initialize``initialized`,把 Profile 选择的模式映射为官方 `thread/start` approvalreviewersandbox 字段并与 `{ cwd, ephemeral: true }` 一起发送,且仅在 Codex 返回有效的临时线程后才发布此次运行。若在发布前发生失败或取消,它会关闭通信链路、终止受管进程树并等待其退出,然后拒绝 `start()` 调用。
`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。随后,它通过 [`dsh-subprocess`](../../subprocess/subprocess/README.md) spawn 固定命令,依次执行 `initialize``initialized`,把 Profile 选择的模式映射为官方 `thread/start` approvalreviewersandbox 字段并与 `{ cwd, ephemeral: true }` 一起发送,且仅在 Codex 返回有效的临时线程后才发布此次运行。若在发布前发生失败或取消,它会关闭通信链路、终止受管进程树并等待其退出,然后拒绝 `start()` 调用。非取消拒绝只公开固定的 `initialize``thread-start` 阶段及已经观测到的进程结果;原始产品与 Host 错误只保留在内部 cause 链中。
已发布的 `run.result` 恰好启动一个轮次。它只接受与此次运行的线程和轮次匹配的通知,随后等待权威的终止通知 `turn/completed`。以最后一条 `phase: "final_answer"``agentMessage` 为准;若 Codex 没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退。过程说明绝不会取代上述任一答案;成功完成的轮次若没有非空白答案,结果也会判为错误。
对于命令与文件审批,无人值守的提供方会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它对权限请求返回作用域限于当前轮次的空权限集,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败。wire 只记录有效模式、请求类别、决定与固定的安全原因,也会识别被拒绝的命令/文件 item 和 `sandboxError` 终态。Codex 0.147.0 的部分早期 `never` 拒绝和 sandbox violation 只写入结构化 stderr,因此提供方会 pipe stderr、原样转发给 Host,并在每次运行的有界尾缓冲中匹配两个固定签名;原始 stderr 不会进入诊断。
本地取消会在结果竞态中胜出并映射为 `aborted`。失败轮次`codexErrorInfo` 若为 `contextWindowExceeded`,则映射为 `max-tokens`;其他任何远端中断或失败轮次都映射为 `error`,且该提供方不会产生 `refusal`权限相关错误可以额外携带有界、非 assistant 的 `SubagentResult.diagnostic`成功本地取消不会附带它。`dispose()`(资源释放)具有幂等性:如果当前的两个标识符均已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,等待整棵进程树退出,并移除 stderr observer。结果失败与独立的清理失败仍彼此分离
本地取消会在结果竞态中胜出并映射为 `aborted`对于失败轮次,诊断会保留 Codex 0.147.0 `codexErrorInfo` 联合中的全部十一种字符串与五种对象 variant;四种连接/stream variant 会在上游提供时保留数值 `httpStatusCode`,而 `activeTurnNotSteerable` 不公开 `turnKind`。诊断还会注明 `turn-start``turn``process`,分别包含可用的退出码与信号,并对无法识别或格式错误的值使用 `unknown`,且不复制原始字段。`contextWindowExceeded`映射为 `max-tokens`;其他任何远端中断或失败映射为 `error`,且该提供方不会产生 `refusal`参与失败的权限决定会跟在结构化失败行之后。成功本地取消都不附带这两类事实
`dispose()`(资源释放)具有幂等性:如果当前的两个标识符均已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,等待整棵进程树退出,并移除 stderr observer。独立清理拒绝使用固定的 `teardown` 阶段与可用进程结果。当启动与回滚同时失败时,顶层聚合消息会保留两条安全阶段说明,而原始失败仍只在内部可见。
## 能力与上下文
@@ -92,13 +94,11 @@ dsh --profile <name>
## 产品兼容性与证据
生产环境的协议层有意只实现这一单次执行约定所需的 app-server 方法。运行时依赖与六个 optional-dependency alias 均锁定到 `@openai/codex@0.147.0` / `codex-cli 0.147.0`。普通安装会按当前操作系统与 CPU 选择一个载荷。对于当前 darwin-arm64 载荷,`npm pack --dry-run --json @openai/codex@0.147.0-darwin-arm64` 报告压缩包为 111,199,052 字节、解包后为 274,777,843 字节。该包包含原生 `codex``codex-code-mode-host``rg``zsh` 资源;其他平台可能不同,这些数值只用于披露而不是安装阈值。无密钥真实产品测试会驱动包内 wrapper 连接回环 Responses fixture,观测包内 argv,并证明 wrapper 与原生后代进程完全停稳。
生产环境的协议层有意只实现这一单次执行约定所需的 app-server 方法。运行时依赖与六个 optional-dependency alias 均锁定到 `@openai/codex@0.147.0` / `codex-cli 0.147.0`。普通安装会按当前操作系统与 CPU 选择一个载荷。对于当前 darwin-arm64 载荷,`npm pack --dry-run --json @openai/codex@0.147.0-darwin-arm64` 报告压缩包为 111,199,052 字节、解包后为 274,777,843 字节。该包包含原生 `codex``codex-code-mode-host``rg``zsh` 资源;其他平台可能不同,这些数值只用于披露而不是安装阈值。
如果安装时省略 optional dependencies、当前平台不受支持,或所选载荷缺失,第一次委派会以 wrapper 的原生载荷启动错误失败。提供方既不会探测宿主 CLI,也不会用它重试
生成的 schema 证据与包测试会固定全部十六种 error-info variant、HTTP status 所在位置、六个生命周期阶段、进程结果、终止原因映射、unknown 回退、脱敏、权限顺序、取消、并发与清理聚合。无密钥真实产品测试会驱动包内 wrapper 连接回环 Responses fixture,并观测包内 argv、确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、线程级 `never` 对环境中 `on-request` 的覆盖、自动评审启动、不产生文件副作用的无人值守拒绝、真实 `internalServerError`、测试拥有临时存储中的显式危险绕过写入、携带安全退出事实的进程/协议失败,以及 wrapper/原生进程完全停稳。同一层级还会证明两个命名实例保留彼此独立的环境与原生模式
真实产品覆盖还会证明线程级 `never` 覆盖环境中的 `on-request`,自动评审通过官方 app-server 启动,危险绕过只在测试拥有的临时存储中写入,安全诊断不包含原始命令与路径,而且所有 wrapper/native 进程都会退出
同一真实产品层级还会证明两个命名实例保留彼此独立的环境与原生模式。
如果安装时省略 optional dependencies、当前平台不受支持,或所选载荷缺失,第一次委派会在 `initialize` 阶段以安全 `unknown` 类别和已观测到的进程结果失败。原始 wrapper 文本只保留在 Host stderr;提供方既不会探测宿主 CLI,也不会用它重试。独立 wrapper fixture 会另行证明原生载荷失败与不存在宿主回退
## 模型体验
@@ -120,7 +120,7 @@ Codex 子级会在一个全新的临时线程中,以单个轮次接收这些
#### 模型看到的内容
通过 `dsh-tool-subagent`,前台调用会让父级模型看到选定的 Codex 最终答案;若结果未完成,错误中会包含终止原因和可选的安全诊断。后台调用会先返回 Job id;随后通用作业控制面会送达完成通知,通过 `job_output` 公开最终答案或失败状态 detail,并允许 `job_kill` 请求取消。Codex 的过程说明、推理(reasoning)、工具活动、原始 stderr、工作区差异、用量信息、产品标识符、命令、路径和协议载荷均不会复制到父会话。
通过 `dsh-tool-subagent`,前台调用会让父级模型看到选定的 Codex 最终答案;若结果未完成,错误中会包含终止原因和可选的安全诊断。该诊断可以区分固定 error-info 类别、协议阶段、数值 HTTP status 和已观测的进程结果,而不复制产品正文。后台调用会先返回 Job id;随后通用作业控制面会送达完成通知,通过 `job_output` 公开同一最终答案或失败状态 detail,并允许 `job_kill` 请求取消。Codex 的过程说明、推理(reasoning)、工具活动、原始 stderr、工作区差异、用量信息、产品标识符、命令、路径和协议载荷均不会复制到父会话。
#### 对 token 的影响
@@ -134,7 +134,7 @@ Codex 子级会在一个全新的临时线程中,以单个轮次接收这些
- **每次运行均新建一个进程、一个线程和一个轮次**:不支持续接、恢复、池化、进度流或产品会话持久化。
- **静态选择实例**:Profile 配置项固定提供方名称与工具绑定;调用无法动态选择提供方,而且每个公开工具都需要唯一的 `toolName`
- **身份验证与账户状态仍由原生机制管理**:Bundle 会提供 CLI,但不会创建账户、登录、信任项目或改写 Codex 设置;配置与身份验证失败会呈现为启动错误或运行错误
- **身份验证与账户状态仍由原生机制管理**:Bundle 会提供 CLI,但不会创建账户、登录、信任项目或改写 Codex 设置;配置与身份验证失败会公开其生命周期阶段与安全的 `unknown` 回退,而不会增加单独的公开分类体系
- **委派时必须存在原生平台载荷**:省略 optional dependencies 的安装、不受支持的平台以及缺失或损坏的载荷都会在第一次运行时失败;不会回退到宿主 CLI。
- **兼容性由开发证据锁定**:若要从已验证的 0.147.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消、无密钥真实产品以及带密钥的 DeepSeek 随机数测试。
- **没有人工审批路径**:已知的无人值守审批请求会被拒绝,未知服务器请求会以默认拒绝方式使运行失败;三种 Profile 模式都不会创建 DSH 交互通道或逐次调用 allow 策略。
+15 -3
View File
@@ -21,6 +21,7 @@ import {
CODEX_PERMISSION_MODES,
DEFAULT_CODEX_PERMISSION_MODE,
DEFAULT_DISPOSE_GRACE_MS,
codexStartupFailure,
startCodexRun,
type CodexPermissionMode,
type CodexRunSpec,
@@ -73,12 +74,23 @@ class CodexProvider implements SubagentProvider {
'subagent-codex: no working directory for the child — delegate from a parent session that has one',
)
}
const spec: CodexRunSpec = {
cwd: resolveChildCwd(
let cwd: string
try {
cwd = resolveChildCwd(
'subagent-codex',
undefined,
parentCwd,
),
)
} catch (error: unknown) {
if (request.signal.aborted) {
throw new Error(
'subagent-codex: request was aborted before app-server startup',
)
}
throw codexStartupFailure(error)
}
const spec: CodexRunSpec = {
cwd,
permissionMode: this.config.permissionMode,
env: this.config.env,
disposeGraceMs: this.config.disposeGraceMs,
+208 -61
View File
@@ -21,13 +21,18 @@ import {
type SubagentStartRequest,
type SubagentStopReason,
} from '@deepseek-ai/dsh-subagent'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { CodexAppServerWire } from './wire.ts'
import type {
SubprocessHandle,
SubprocessOutcome,
SubprocessSpawnSpec,
} from '@deepseek-ai/dsh-subprocess'
import {
CodexAppServerWire,
type CodexWireFailureFacts,
} from './wire.ts'
/** Default POSIX grace between subprocess termination tiers. */
export const DEFAULT_DISPOSE_GRACE_MS = 3_000
/** Bounded stderr tail retained only to recognize the wrapper's payload error. */
const CODEX_STDERR_TAIL_BYTES = 16 * 1024
interface CodexPackageManifest {
readonly bin: {
@@ -46,23 +51,6 @@ const CODEX_PACKAGE_BIN = resolve(
codexPackageManifest.bin.codex,
)
function missingPayloadDiagnostic(stderr: string): string | undefined {
const platformPackage = /Missing optional dependency (@openai\/codex-[a-z0-9-]+)/
.exec(stderr)?.[1]
return platformPackage === undefined
? undefined
: `Missing optional dependency ${platformPackage}`
}
function withMissingPayloadDiagnostic(
error: Error,
stderr: string,
): Error {
const diagnostic = missingPayloadDiagnostic(stderr)
if (diagnostic === undefined || error.message.includes(diagnostic)) return error
return new Error(`${error.message}: ${diagnostic}`, { cause: error })
}
/** Profile-selectable non-interactive Codex permission mode. */
export type CodexPermissionMode =
| 'never'
@@ -79,6 +67,64 @@ export const CODEX_PERMISSION_MODES = [
/** Safe default for unattended Codex runs. */
export const DEFAULT_CODEX_PERMISSION_MODE: CodexPermissionMode = 'never'
type CodexFailureStage =
| 'initialize'
| 'thread-start'
| CodexWireFailureFacts['stage']
| 'process'
| 'teardown'
interface CodexFailureFacts {
readonly stage: CodexFailureStage
readonly category: string
readonly httpStatus?: number | undefined
readonly outcome?: SubprocessOutcome | undefined
}
function failureDiagnostic(facts: CodexFailureFacts): string {
const fields = [
'product: Codex',
`stage: ${facts.stage}`,
`category: ${facts.category}`,
]
if (facts.httpStatus !== undefined) {
fields.push(`HTTP status: ${facts.httpStatus}`)
}
const processFields = [
['exit code', facts.outcome?.exitCode],
['signal', facts.outcome?.signal],
] as const
for (const [label, value] of processFields) {
if (value !== null && value !== undefined) fields.push(`${label}: ${value}`)
}
return `Product subagent failure (${fields.join('; ')})`
}
class CodexRunFailure extends Error {
constructor(
readonly facts: CodexFailureFacts,
cause?: unknown,
) {
super(
`subagent-codex: ${failureDiagnostic(facts)}`,
cause === undefined ? undefined : { cause },
)
this.name = 'CodexRunFailure'
}
}
/**
* Hide an unpublished Host failure behind fixed safe startup facts.
* @param cause Original Host failure retained for internal diagnostics.
* @returns A startup failure whose message contains only fixed safe facts.
*/
export function codexStartupFailure(cause: unknown): Error {
return new CodexRunFailure({
stage: 'initialize',
category: 'unknown',
}, cause)
}
/**
* Fixed package-local app-server command, independent of the host `PATH`.
* @returns Node, the official wrapper, and the fixed app-server arguments.
@@ -141,18 +187,33 @@ export async function disposeCodexChild(
child: SubprocessHandle,
): Promise<void> {
wire.close()
if (child.pid <= 0) {
if (child.pid > 0) {
let outcome: SubprocessOutcome | undefined
void child.done.then(
(value) => { outcome = value },
/* v8 ignore next -- a positive pid excludes spawn-level done rejection. */
() => {},
)
try {
child.stdin?.end()
} catch {
// A concurrently closed stdin does not change tree ownership below.
}
child.terminate()
try {
await child.waitForExit()
} catch (error: unknown) {
throw new CodexRunFailure({
stage: 'teardown',
category: 'unknown',
outcome,
}, thrown(error))
}
await child.done
} else {
await child.done.catch(() => {})
return
}
try {
child.stdin?.end()
} catch {
// A concurrently closed stdin does not change tree ownership below.
}
child.terminate()
await child.waitForExit()
await child.done
}
/**
@@ -170,26 +231,29 @@ export async function startCodexRun(
throw new Error('subagent-codex: request was aborted before app-server startup')
}
const child = spec.spawn({
argv: codexAppServerArgv(),
cwd: spec.cwd,
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
graceMs: spec.disposeGraceMs,
env: spec.env,
})
let child: SubprocessHandle
try {
child = spec.spawn({
argv: codexAppServerArgv(),
cwd: spec.cwd,
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
graceMs: spec.disposeGraceMs,
env: spec.env,
})
} catch (error: unknown) {
throw new CodexRunFailure({
stage: 'initialize',
category: 'unknown',
}, thrown(error))
}
const wire = new CodexAppServerWire(
child.stdout as NonNullable<SubprocessHandle['stdout']>,
child.stdin as NonNullable<SubprocessHandle['stdin']>,
spec.permissionMode,
)
let stderrTail = Buffer.alloc(0)
const onStderr = (chunk: Buffer | string): void => {
const bytes = typeof chunk === 'string' ? Buffer.from(chunk) : chunk
const combined = Buffer.concat([stderrTail, bytes])
stderrTail = combined.length > CODEX_STDERR_TAIL_BYTES
? Buffer.from(combined.subarray(combined.length - CODEX_STDERR_TAIL_BYTES))
: combined
wire.observeStderr(bytes.toString())
try {
// Synchronous fd forwarding preserves byte order without owning a
@@ -217,15 +281,26 @@ export async function startCodexRun(
}
}
const processFailure: Promise<never> = child.done.then(
outcome => Promise.reject(new Error(
'subagent-codex: app-server exited before the run settled '
+ `(code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`,
)),
(error: unknown) => Promise.reject(thrown(error)),
let processFailureFacts: CodexFailureFacts | undefined
const processFailure: Promise<never> = child.done.then<never>(
(outcome) => {
processFailureFacts = {
stage: 'process',
category: 'process-exit',
outcome,
}
throw new CodexRunFailure(processFailureFacts)
},
(error: unknown) => {
processFailureFacts = {
stage: 'process',
category: 'unknown',
}
throw new CodexRunFailure(processFailureFacts, thrown(error))
},
)
// A normal post-result dispose also closes the process. Keep that expected
// late rejection observed after the result race has already settled.
// A normal post-result dispose also closes the process. Keep its expected
// late rejection observed when the terminal result settles first.
processFailure.catch(() => {})
const runAbort = new AbortController()
@@ -237,44 +312,116 @@ export async function startCodexRun(
const onAbort = (): void => { requestCancel() }
request.signal.addEventListener('abort', onAbort, { once: true })
let startupStage: 'initialize' | 'thread-start' = 'initialize'
try {
wire.start()
await Promise.race([wire.initialize(request.signal), processFailure])
startupStage = 'thread-start'
await Promise.race([wire.startThread(spec.cwd, request.signal), processFailure])
} catch (error: unknown) {
request.signal.removeEventListener('abort', onAbort)
const startupCause = thrown(error)
const cancelledBeforeCleanup = runAbort.signal.aborted
if (!(error instanceof CodexRunFailure) && !cancelledBeforeCleanup) {
// Node reports stdout EOF before the child close that owns its outcome.
// Let an already-exiting process publish those facts before rollback.
await new Promise<void>((resolve) => { setImmediate(resolve) })
}
const failure = new CodexRunFailure({
stage: startupStage,
category: 'unknown',
outcome: error instanceof CodexRunFailure
? error.facts.outcome
: processFailureFacts?.outcome,
}, thrown(error))
try {
await disposeProcess()
} catch (disposeError: unknown) {
const cleanupFailure = thrown(disposeError)
throw new AggregateError(
[withMissingPayloadDiagnostic(startupCause, stderrTail.toString()), thrown(disposeError)],
'subagent-codex: startup failed and app-server cleanup also failed',
[failure, cleanupFailure],
`${failure.message}; ${cleanupFailure.message}`,
)
}
if (runAbort.signal.aborted) {
if (cancelledBeforeCleanup) {
throw new Error('subagent-codex: request was aborted before run publication')
}
throw withMissingPayloadDiagnostic(startupCause, stderrTail.toString())
try {
request.signal.throwIfAborted()
} catch {
throw new Error('subagent-codex: request was aborted before run publication')
}
throw failure
}
const collectOutput = (): ContentBlock[] => wire.collectOutput()
let diagnostic: string | undefined
const recordFailureDiagnostic = (facts: CodexFailureFacts): string => {
const failure = failureDiagnostic(facts)
const permission = wire.collectDiagnostic()
diagnostic = permission === undefined
? failure
: `${failure}\n${permission}`
return diagnostic
}
const withProcessOutcome = (facts: CodexFailureFacts): CodexFailureFacts => {
const outcome = processFailureFacts?.outcome
return outcome === undefined
? facts
: { ...facts, outcome }
}
const publishedProcessFailure = processFailure.catch(
async (error: unknown): Promise<never> => {
// Frames already queued by the exiting app-server remain authoritative.
// One I/O turn lets them settle before process exit ends the run.
await new Promise<void>((resolve) => { setImmediate(resolve) })
throw error
},
)
const result: Promise<SubagentResult> = settleRunResult({
attempt: async () => {
try {
return await Promise.race([
const terminal = await Promise.race([
wire.runTurn(texts, runAbort.signal),
processFailure,
publishedProcessFailure,
])
if (terminal.stopReason === 'completed') return terminal
// Let stderr already queued with the terminal frame contribute its
// fixed permission fact before the non-completed result is snapshotted.
await new Promise<void>((resolve) => { setImmediate(resolve) })
const facts = withProcessOutcome(wire.collectFailure())
return { ...terminal, diagnostic: recordFailureDiagnostic(facts) }
} catch (error: unknown) {
// Give stderr data already queued in Node one turn to reach the wire
// before settlement snapshots the diagnostic; later OS data is best-effort.
// before settlement snapshots the diagnostic.
await new Promise<void>((resolve) => { setImmediate(resolve) })
throw withMissingPayloadDiagnostic(thrown(error), stderrTail.toString())
const endedBeforeTerminal = wire.endedBeforeTerminal()
if (
endedBeforeTerminal
&& processFailureFacts === undefined
&& !runAbort.signal.aborted
) {
try {
const exited = await child.waitForExit(
AbortSignal.timeout(Math.ceil(spec.disposeGraceMs)),
)
if (exited) await child.done
} catch {
// The wire failure remains authoritative when exit observation fails.
}
}
const facts = error instanceof CodexRunFailure
? error.facts
: endedBeforeTerminal && processFailureFacts !== undefined
? processFailureFacts
: withProcessOutcome(wire.collectFailure())
recordFailureDiagnostic(facts)
throw error instanceof CodexRunFailure
? error
: new CodexRunFailure(facts, thrown(error))
}
},
collectOutput,
collectDiagnostic: () => wire.collectDiagnostic(),
collectDiagnostic: () => diagnostic,
cancelled: () => runAbort.signal.aborted,
onError: spec.onError,
signal: request.signal,
+140 -32
View File
@@ -15,6 +15,13 @@ import type { CodexPermissionMode } from './run.ts'
type JsonObject = Record<string, unknown>
/** Product facts owned by the Codex wire after publication. */
export interface CodexWireFailureFacts {
readonly stage: 'turn-start' | 'turn'
readonly category: string
readonly httpStatus?: number | undefined
}
const THREAD_PERMISSION_PARAMS: Readonly<Record<CodexPermissionMode, JsonObject>> = {
never: { approvalPolicy: 'never' },
'approve-for-me': {
@@ -86,22 +93,78 @@ function unattendedDecision(params: JsonObject): 'cancel' | 'decline' {
throw new Error('subagent-codex: app-server offered no unattended approval decision')
}
function isContextWindowExceeded(turn: JsonObject): boolean {
if (turn.status !== 'failed') return false
const error = turn.error
return error !== null
&& typeof error === 'object'
&& !Array.isArray(error)
&& (error as JsonObject).codexErrorInfo === 'contextWindowExceeded'
function numericHttpStatus(value: unknown): number | undefined {
return typeof value === 'number'
&& Number.isInteger(value)
&& value >= 0
&& value <= 65_535
? value
: undefined
}
function isSandboxFailure(turn: JsonObject): boolean {
if (turn.status !== 'failed') return false
function objectFailureInfo(value: JsonObject): {
readonly category: string
readonly httpStatus?: number | undefined
} {
const keys = Object.keys(value)
const category = keys[0]
if (keys.length !== 1 || category === undefined) {
return { category: 'unknown' }
}
const detail = value[category]
if (detail === null || typeof detail !== 'object' || Array.isArray(detail)) {
return { category: 'unknown' }
}
const fields = detail as JsonObject
switch (category) {
case 'httpConnectionFailed':
case 'responseStreamConnectionFailed':
case 'responseStreamDisconnected':
case 'responseTooManyFailedAttempts':
{
const httpStatus = numericHttpStatus(fields.httpStatusCode)
return httpStatus === undefined
? { category }
: { category, httpStatus }
}
case 'activeTurnNotSteerable':
return { category }
default:
return { category: 'unknown' }
}
}
function failureInfo(turn: JsonObject): {
readonly category: string
readonly httpStatus?: number | undefined
} {
if (turn.status !== 'failed') return { category: 'unknown' }
const error = turn.error
return error !== null
&& typeof error === 'object'
&& !Array.isArray(error)
&& (error as JsonObject).codexErrorInfo === 'sandboxError'
if (error === null || typeof error !== 'object' || Array.isArray(error)) {
return { category: 'unknown' }
}
const info = (error as JsonObject).codexErrorInfo
if (typeof info === 'string') {
switch (info) {
case 'contextWindowExceeded':
case 'sessionBudgetExceeded':
case 'usageLimitExceeded':
case 'serverOverloaded':
case 'cyberPolicy':
case 'internalServerError':
case 'unauthorized':
case 'badRequest':
case 'threadRollbackFailed':
case 'sandboxError':
case 'other':
return { category: info }
default:
return { category: 'unknown' }
}
}
return info !== null && typeof info === 'object' && !Array.isArray(info)
? objectFailureInfo(info as JsonObject)
: { category: 'unknown' }
}
function unattendedDiagnostic(
@@ -164,6 +227,7 @@ export class CodexAppServerWire {
private lastFinalAnswer: string | undefined
private lastUnphasedAnswer: string | undefined
private diagnostic: string | undefined
private failure: CodexWireFailureFacts | undefined
private diagnosticOrder = 0
private observationOrder = 0
private pendingDiagnostic: {
@@ -173,6 +237,8 @@ export class CodexAppServerWire {
readonly reason: string
} | undefined
private stderrTail = ''
private inputEnded = false
private terminalObserved = false
private closed = false
constructor(
@@ -206,6 +272,14 @@ export class CodexAppServerWire {
this.transport.start()
}
/**
* Whether protocol output ended before a terminal turn notification.
* @returns `true` only for an early protocol close without a terminal turn.
*/
endedBeforeTerminal(): boolean {
return this.inputEnded && !this.terminalObserved
}
/**
* Perform the required app-server initialize/initialized handshake.
* @param signal - unpublished-start cancellation.
@@ -262,22 +336,41 @@ export class CodexAppServerWire {
}>()
this.turnCompleted = completion
const threadId = this.threadId as string
const response = object(await this.guarded(this.transport.request('turn/start', {
threadId,
input: texts.map(text => ({ type: 'text', text, text_elements: [] })),
}, signal), signal), 'turn/start response')
const turn = object(response.turn, 'turn/start turn')
this.commitTurnId(string(turn.id, 'turn/start turn id'))
const completed = await this.guarded(completion.promise, signal)
const terminal = object(completed.params.turn, 'turn/completed turn')
const status = terminal.status
if (isContextWindowExceeded(terminal)) {
return { output: this.collectOutput(), stopReason: 'max-tokens' }
try {
const response = object(await this.guarded(this.transport.request('turn/start', {
threadId,
input: texts.map(text => ({ type: 'text', text, text_elements: [] })),
}, signal), signal), 'turn/start response')
const turn = object(response.turn, 'turn/start turn')
this.commitTurnId(string(turn.id, 'turn/start turn id'))
} catch (error: unknown) {
this.recordFailure({ stage: 'turn-start', category: 'unknown' })
throw error
}
let completed: {
readonly params: JsonObject
readonly order: number
}
let terminal: JsonObject
try {
completed = await this.guarded(completion.promise, signal)
terminal = object(completed.params.turn, 'turn/completed turn')
} catch (error: unknown) {
this.recordFailure({ stage: 'turn', category: 'unknown' })
throw error
}
const status = terminal.status
if (status !== 'completed') {
const sandboxFailure = isSandboxFailure(terminal)
if (sandboxFailure) {
const parsed = failureInfo(terminal)
this.recordFailure(parsed.httpStatus === undefined
? { stage: 'turn', category: parsed.category }
: {
stage: 'turn',
category: parsed.category,
httpStatus: parsed.httpStatus,
})
if (parsed.category === 'sandboxError') {
this.recordDiagnostic(
'sandbox execution',
'failed',
@@ -285,15 +378,15 @@ export class CodexAppServerWire {
completed.order,
)
}
const detail = status === 'failed'
? sandboxFailure
? ': sandboxError'
: ': error'
: ''
if (parsed.category === 'contextWindowExceeded') {
return { output: this.collectOutput(), stopReason: 'max-tokens' }
}
const detail = status === 'failed' ? `: ${parsed.category}` : ''
throw new Error(`subagent-codex: Codex turn ended with status ${String(status)}${detail}`)
}
const output = this.collectOutput()
if (output.length === 0) {
this.recordFailure({ stage: 'turn', category: 'unknown' })
throw new Error('subagent-codex: Codex completed without a final answer')
}
return { output, stopReason: 'completed' }
@@ -330,6 +423,15 @@ export class CodexAppServerWire {
return this.diagnostic
}
/**
* The structured failure fact observed for this published turn.
* Call only after a non-completed return or rejection from {@link runTurn}.
* @returns the fixed stage/category pair and optional HTTP status.
*/
collectFailure(): CodexWireFailureFacts {
return this.failure as CodexWireFailureFacts
}
/**
* Observe product stderr while retaining only enough tail to recognize fixed
* permission signatures. The raw text is never copied into the diagnostic.
@@ -378,6 +480,7 @@ export class CodexAppServerWire {
}
private readonly onInputEnd = (): void => {
this.inputEnded = true
this.fail(new Error('subagent-codex: app-server protocol stream closed'))
}
@@ -475,6 +578,10 @@ export class CodexAppServerWire {
)
}
private recordFailure(facts: CodexWireFailureFacts): void {
this.failure = facts
}
private nextObservationOrder(): number {
this.observationOrder += 1
return this.observationOrder
@@ -625,6 +732,7 @@ export class CodexAppServerWire {
return
}
if (id !== this.turnId) return
this.terminalObserved = true
if (!['completed', 'interrupted', 'failed'].includes(String(turn.status))) {
throw new Error(`subagent-codex: app-server returned invalid terminal turn status ${String(turn.status)}`)
}
@@ -17,7 +17,11 @@ import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import type {
SubprocessHandle,
SubprocessOutcome,
SubprocessSpawnSpec,
} from '@deepseek-ai/dsh-subprocess'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import * as codex from '../src/index.ts'
import type { CodexPermissionMode } from '../src/run.ts'
@@ -175,6 +179,26 @@ async function expectQuiescent(handles: readonly SubprocessHandle[]): Promise<vo
}
}
function expectedProcessExitDiagnostic(outcome: SubprocessOutcome): string {
const fields = [
'product: Codex',
'stage: process',
'category: process-exit',
]
if (outcome.exitCode !== null) fields.push(`exit code: ${outcome.exitCode}`)
if (outcome.signal !== null) fields.push(`signal: ${outcome.signal}`)
return `Product subagent failure (${fields.join('; ')})`
}
interface JsonSchemaNode {
readonly enum?: string[]
readonly format?: string
readonly minimum?: number
readonly properties?: Record<string, JsonSchemaNode>
readonly required?: string[]
readonly type?: string | string[]
}
function responseInputTexts(body: Record<string, unknown>): string[] {
if (!Array.isArray(body.input)) return []
return body.input.flatMap((item): string[] => {
@@ -203,6 +227,56 @@ describe('real @openai/codex 0.147.0 product', () => {
env: { ...process.env, ...harness.env },
})
expect(version.stdout.trim()).toBe('codex-cli 0.147.0')
const schemaRoot = mkdtempSync(join(tmpdir(), 'dsh-codex-schema-'))
roots.push(schemaRoot)
await execFileAsync(process.execPath, [
codexEntry,
'app-server',
'generate-json-schema',
'--out',
schemaRoot,
], { env: { ...process.env, ...harness.env } })
const schema = JSON.parse(readFileSync(
join(schemaRoot, 'ServerNotification.json'),
'utf8',
)) as {
definitions: {
CodexErrorInfo: {
oneOf: JsonSchemaNode[]
}
}
}
expect(schema.definitions.CodexErrorInfo.oneOf[0]?.enum).toEqual([
'contextWindowExceeded',
'sessionBudgetExceeded',
'usageLimitExceeded',
'serverOverloaded',
'cyberPolicy',
'internalServerError',
'unauthorized',
'badRequest',
'threadRollbackFailed',
'sandboxError',
'other',
])
expect(schema.definitions.CodexErrorInfo.oneOf.slice(1).map(variant =>
Object.keys(variant.properties ?? {})[0])).toEqual([
'httpConnectionFailed',
'responseStreamConnectionFailed',
'responseStreamDisconnected',
'responseTooManyFailedAttempts',
'activeTurnNotSteerable',
])
for (const variant of schema.definitions.CodexErrorInfo.oneOf.slice(1, 5)) {
const category = Object.keys(variant.properties ?? {})[0]!
const detail = variant.properties?.[category]
expect(detail?.required).toBeUndefined()
expect(detail?.properties?.httpStatusCode).toEqual({
format: 'uint16',
minimum: 0,
type: ['integer', 'null'],
})
}
const run = await harness.ctx.subagents.start('codex', {
prompt: [{ type: 'text', text: task }],
@@ -364,11 +438,15 @@ describe('real @openai/codex 0.147.0 product', () => {
const result = await run.result
expect(result.output).toEqual([])
expect(result.stopReason).toBe('error')
const diagnosticLines = result.diagnostic?.split('\n') ?? []
expect(diagnosticLines[0]).toBe(
'Product subagent failure (product: Codex; stage: turn; category: other)',
)
expect([
'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval',
'Codex unattended decision (mode: never; request: sandbox execution; decision: failed): Codex reported a sandbox failure',
'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval',
]).toContain(result.diagnostic)
]).toContain(diagnosticLines[1])
expect(result.diagnostic).not.toContain(command)
expect(result.diagnostic).not.toContain(harness.workspace)
await run.dispose()
@@ -385,6 +463,49 @@ describe('real @openai/codex 0.147.0 product', () => {
await expectQuiescent(harness.handles)
}, 60_000)
it('reports a real service failure and an early app-server exit safely', async () => {
{
const { harness } = await realHarness([{
kind: 'error',
status: 503,
message: 'SECRET_TOKEN in /private/secret.txt',
}])
const run = await harness.ctx.subagents.start('codex', {
prompt: [{ type: 'text', text: 'Exercise the service failure path.' }],
parent: harness.parent,
signal: new AbortController().signal,
})
const result = await run.result
expect(result).toMatchObject({ output: [], stopReason: 'error' })
expect(result.diagnostic).toBe(
'Product subagent failure (product: Codex; stage: turn; category: internalServerError)',
)
expect(result.diagnostic).not.toContain('SECRET_TOKEN')
expect(result.diagnostic).not.toContain('/private/secret.txt')
await run.dispose()
await expectQuiescent(harness.handles)
}
{
const { harness, fixture } = await realHarness([{ kind: 'hold' }])
const run = await harness.ctx.subagents.start('codex', {
prompt: [{ type: 'text', text: 'Exercise the process failure path.' }],
parent: harness.parent,
signal: new AbortController().signal,
})
await fixture.requestStarted
expect(harness.handles).toHaveLength(1)
harness.handles[0]!.terminate()
const outcome = await harness.handles[0]!.done
await expect(run.result).resolves.toEqual({
output: [],
diagnostic: expectedProcessExitDiagnostic(outcome),
stopReason: 'error',
})
await run.dispose()
await expectQuiescent(harness.handles)
}
}, 60_000)
it('executes an explicitly selected dangerous bypass write in the isolated workspace', async () => {
const sideEffect = 'bypass-side-effect'
const { harness, fixture } = await realHarness((workspace): readonly ResponsesBehavior[] => {
@@ -143,6 +143,7 @@ interface FakeChildOptions {
readonly pid?: number
readonly exitOnTerminate?: boolean
readonly doneError?: Error
readonly waitForExitError?: Error
}
interface FakeChild {
@@ -187,6 +188,9 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild {
if (options.exitOnTerminate !== false) settle()
})
const waitForExit = vi.fn(async (signal?: AbortSignal) => {
if (options.waitForExitError !== undefined) {
throw options.waitForExitError
}
if (exited) return true
if (signal === undefined) {
await done.catch(() => {})
@@ -322,6 +326,37 @@ function turnCompleted(
}
}
function expectedFailureDiagnostic(
stage: 'initialize' | 'thread-start' | 'turn-start' | 'turn' | 'process' | 'teardown',
category: string,
options: {
readonly httpStatus?: number
readonly outcome?: Partial<SubprocessOutcome>
} = {},
): string {
const fields = [
'product: Codex',
`stage: ${stage}`,
`category: ${category}`,
]
if (options.httpStatus !== undefined) {
fields.push(`HTTP status: ${options.httpStatus}`)
}
if (
options.outcome?.exitCode !== null
&& options.outcome?.exitCode !== undefined
) {
fields.push(`exit code: ${options.outcome.exitCode}`)
}
if (
options.outcome?.signal !== null
&& options.outcome?.signal !== undefined
) {
fields.push(`signal: ${options.outcome.signal}`)
}
return `Product subagent failure (${fields.join('; ')})`
}
describe('task admission and package contracts', () => {
it('ships one independently installable provider-only Bundle patch', () => {
const root = fileURLToPath(new URL('..', import.meta.url))
@@ -380,11 +415,6 @@ describe('task admission and package contracts', () => {
expect(JSON.stringify(rows)).not.toContain('tool-subagent')
})
it('uses only the official package-declared wrapper for app-server', () => {
expect(codexAppServerArgv()[0]).toBe(process.execPath)
expect(codexAppServerArgv().slice(2)).toEqual(['app-server', '--stdio'])
})
it('accepts one or more text blocks and rejects empty or non-text tasks', () => {
expect(textTask([
{ type: 'text', text: 'one' },
@@ -740,23 +770,103 @@ describe('CodexAppServerWire', () => {
wire.close()
})
it('maps only an explicit context-window failure to max-tokens', async () => {
const { child, wire } = await initializeWire()
const result = wire.runTurn(['task'], new AbortController().signal)
const turnStart = await child.peer.nextMethod('turn/start')
child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
child.peer.send(
agentMessage('partial answer', null),
turnCompleted('failed', 'turn-1', 'thread-1', {
message: 'too much context',
codexErrorInfo: 'contextWindowExceeded',
}),
)
await expect(result).resolves.toEqual({
output: [{ type: 'text', text: 'partial answer' }],
stopReason: 'max-tokens',
})
wire.close()
it('maps the complete string error union without changing stop reasons', async () => {
const categories = [
'contextWindowExceeded',
'sessionBudgetExceeded',
'usageLimitExceeded',
'serverOverloaded',
'cyberPolicy',
'internalServerError',
'unauthorized',
'badRequest',
'threadRollbackFailed',
'sandboxError',
'other',
] as const
for (const category of categories) {
const { child, wire } = await initializeWire()
const result = wire.runTurn(['task'], new AbortController().signal)
const turnStart = await child.peer.nextMethod('turn/start')
child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
child.peer.send(
agentMessage('partial answer', null),
turnCompleted('failed', 'turn-1', 'thread-1', {
message: 'SECRET_TOKEN in /private/secret.txt',
codexErrorInfo: category,
}),
)
if (category === 'contextWindowExceeded') {
await expect(result).resolves.toEqual({
output: [{ type: 'text', text: 'partial answer' }],
stopReason: 'max-tokens',
})
} else {
await expect(result).rejects.toThrow(`status failed: ${category}`)
}
expect(wire.collectFailure()).toEqual({
stage: 'turn',
category,
})
expect(JSON.stringify(wire.collectFailure())).not.toContain('SECRET_TOKEN')
expect(JSON.stringify(wire.collectFailure())).not.toContain('/private/secret.txt')
wire.close()
}
})
it('maps all object error variants and only numeric HTTP status', async () => {
const scenarios = [
['httpConnectionFailed', { httpStatusCode: 503 }, 503],
['responseStreamConnectionFailed', { httpStatusCode: null }, undefined],
['responseStreamDisconnected', {}, undefined],
['responseTooManyFailedAttempts', { httpStatusCode: '503' }, undefined],
['activeTurnNotSteerable', { turnKind: 'review' }, undefined],
] as const
for (const [category, detail, httpStatus] of scenarios) {
const { child, wire } = await initializeWire()
const result = wire.runTurn(['task'], new AbortController().signal)
const turnStart = await child.peer.nextMethod('turn/start')
child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
message: 'SECRET_TOKEN in /private/secret.txt',
codexErrorInfo: { [category]: detail },
}))
await expect(result).rejects.toThrow(`status failed: ${category}`)
expect(wire.collectFailure()).toEqual({
stage: 'turn',
category,
...(httpStatus === undefined ? {} : { httpStatus }),
})
expect(JSON.stringify(wire.collectFailure())).not.toContain('turnKind')
wire.close()
}
})
it('uses unknown for version-external or malformed error info', async () => {
for (const codexErrorInfo of [
'futureError',
{ futureVariant: { message: 'SECRET_TOKEN' } },
{
httpConnectionFailed: { httpStatusCode: 503 },
otherVariant: {},
},
{ httpConnectionFailed: null },
]) {
const { child, wire } = await initializeWire()
const result = wire.runTurn(['task'], new AbortController().signal)
const turnStart = await child.peer.nextMethod('turn/start')
child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
message: 'SECRET_TOKEN in /private/secret.txt',
codexErrorInfo,
}))
await expect(result).rejects.toThrow('status failed: unknown')
expect(wire.collectFailure()).toEqual({
stage: 'turn',
category: 'unknown',
})
wire.close()
}
})
it('rejects invalid handshake, thread, and turn response shapes', async () => {
@@ -786,6 +896,10 @@ describe('CodexAppServerWire', () => {
const frame = await child.peer.nextMethod('turn/start')
child.peer.respond(frame, { turn: { id: '' } })
await expect(pending).rejects.toThrow('turn/start turn id')
expect(wire.collectFailure()).toEqual({
stage: 'turn-start',
category: 'unknown',
})
wire.close()
}
})
@@ -819,6 +933,10 @@ describe('CodexAppServerWire', () => {
frames: [turnCompleted('failed', 'turn-1', 'thread-1', { message: 'no' })],
message: 'status failed',
},
{
frames: [turnCompleted('failed', 'turn-1', 'thread-1', 'SECRET_TOKEN')],
message: 'status failed',
},
{
frames: [turnCompleted('interrupted')],
message: 'status interrupted',
@@ -833,8 +951,13 @@ describe('CodexAppServerWire', () => {
const result = wire.runTurn(['task'], new AbortController().signal)
const turnStart = await child.peer.nextMethod('turn/start')
child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
await nextTask()
child.peer.send(...scenario.frames)
await expect(result).rejects.toThrow(scenario.message)
expect(wire.collectFailure()).toEqual({
stage: 'turn',
category: 'unknown',
})
wire.close()
}
})
@@ -1458,26 +1581,197 @@ describe('run lifecycle and quiescence', () => {
await run.dispose()
})
it('reports turn-start failures and omits captured facts after success', async () => {
{
const { child, run, turnStart } = await publishRun()
child.peer.respond(turnStart, { turn: { id: '' } })
await expect(run.result).resolves.toEqual({
output: [],
diagnostic: expectedFailureDiagnostic('turn-start', 'unknown'),
stopReason: 'error',
})
await run.dispose()
}
{
const { child, run, turnStart } = await publishRun()
child.peer.send({
id: 'successful-approval',
method: 'item/commandExecution/requestApproval',
params: {
threadId: 'thread-1',
turnId: 'turn-1',
availableDecisions: ['cancel'],
},
})
child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
await child.peer.nextResponse('successful-approval')
child.peer.send(
agentMessage('answer', 'final_answer'),
turnCompleted('completed'),
)
await expect(run.result).resolves.toEqual({
output: [{ type: 'text', text: 'answer' }],
stopReason: 'completed',
})
await run.dispose()
}
})
it('preserves representative terminal categories, HTTP status, and mapping', async () => {
const scenarios = [
['contextWindowExceeded', 'max-tokens', undefined],
['sessionBudgetExceeded', 'error', undefined],
[{ httpConnectionFailed: { httpStatusCode: 503 } }, 'error', 503],
[{ activeTurnNotSteerable: { turnKind: 'review' } }, 'error', undefined],
['futureError', 'error', undefined],
] as const
for (const [codexErrorInfo, stopReason, httpStatus] of scenarios) {
const { child, run, turnStart } = await publishRun()
child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
child.peer.send(
agentMessage('partial answer', null),
turnCompleted('failed', 'turn-1', 'thread-1', {
message: 'SECRET_TOKEN in /private/secret.txt',
codexErrorInfo,
}),
)
const category = typeof codexErrorInfo === 'string'
&& codexErrorInfo !== 'futureError'
? codexErrorInfo
: typeof codexErrorInfo === 'object'
? Object.keys(codexErrorInfo)[0]!
: 'unknown'
const result = await run.result
expect(result).toEqual({
output: [{ type: 'text', text: 'partial answer' }],
diagnostic: expectedFailureDiagnostic('turn', category, {
...(httpStatus === undefined ? {} : { httpStatus }),
}),
stopReason,
})
expect(result.diagnostic).not.toContain('SECRET_TOKEN')
expect(result.diagnostic).not.toContain('/private/secret.txt')
expect(result.diagnostic).not.toContain('turnKind')
await run.dispose()
}
})
it('includes a queued stderr permission fact in a max-token result', async () => {
const { child, run, turnStart } = await publishRun()
child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
setImmediate(() => {
child.stderr.write('approval policy is Never; reject command')
child.peer.send(
agentMessage('partial answer', null),
turnCompleted('failed', 'turn-1', 'thread-1', {
codexErrorInfo: 'contextWindowExceeded',
}),
)
})
child.settle({ exitCode: 17, signal: null })
await expect(run.result).resolves.toEqual({
output: [{ type: 'text', text: 'partial answer' }],
diagnostic: `${expectedFailureDiagnostic('turn', 'contextWindowExceeded', { outcome: { exitCode: 17, signal: null } })}\nCodex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval`,
stopReason: 'max-tokens',
})
await run.dispose()
})
it('flattens child exit and protocol failures after publication', async () => {
const errors: string[] = []
{
const outcomes: SubprocessOutcome[] = [
{ exitCode: 9, signal: null },
{ exitCode: null, signal: 'SIGABRT' },
{ exitCode: null, signal: null },
]
for (const outcome of outcomes) {
const child = fakeChild({ exitOnTerminate: false })
const { run } = await publishRun(child, undefined, {
onError: (error) => { errors.push(error.message) },
})
child.settle({ exitCode: 9, signal: null })
await expect(run.result).resolves.toEqual({ output: [], stopReason: 'error' })
expect(errors.at(-1)).toContain('code 9')
child.settle(outcome)
await expect(run.result).resolves.toEqual({
output: [],
diagnostic: expectedFailureDiagnostic('process', 'process-exit', {
outcome,
}),
stopReason: 'error',
})
expect(errors.at(-1)).toBe(
`subagent-codex: ${expectedFailureDiagnostic('process', 'process-exit', { outcome })}`,
)
await run.dispose().catch(() => {})
}
{
const outcome = { exitCode: 17, signal: null } as const
const child = fakeChild({ exitOnTerminate: false })
const { run, turnStart } = await publishRun(child, undefined, {
disposeGraceMs: 0.5,
})
child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
vi.spyOn(child.handle, 'waitForExit').mockImplementationOnce(async (signal?: AbortSignal) => {
expect(signal).toBeDefined()
child.settle(outcome)
return true
})
child.fromChild.emit('end')
await expect(run.result).resolves.toEqual({
output: [],
diagnostic: expectedFailureDiagnostic('process', 'process-exit', {
outcome,
}),
stopReason: 'error',
})
await run.dispose().catch(() => {})
}
{
const child = fakeChild({ exitOnTerminate: false })
const { run, turnStart } = await publishRun(child)
child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
setImmediate(() => {
child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
codexErrorInfo: 'other',
}))
})
child.settle({ exitCode: 17, signal: null })
await expect(run.result).resolves.toEqual({
output: [],
diagnostic: expectedFailureDiagnostic('turn', 'other', {
outcome: { exitCode: 17, signal: null },
}),
stopReason: 'error',
})
await run.dispose().catch(() => {})
}
{
const child = fakeChild({ exitOnTerminate: false })
const { run, turnStart } = await publishRun(child)
child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
child.peer.send(
agentMessage('answer', 'final_answer'),
turnCompleted('completed'),
)
child.fromChild.end()
child.settle({ exitCode: 17, signal: null })
await expect(run.result).resolves.toEqual({
output: [{ type: 'text', text: 'answer' }],
stopReason: 'completed',
})
await run.dispose().catch(() => {})
}
{
const child = fakeChild()
const { run, turnStart } = await publishRun(child, undefined, {
disposeGraceMs: 10,
onError: () => { throw new Error('diagnostic sink') },
})
child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
child.fromChild.end()
await expect(run.result).resolves.toEqual({ output: [], stopReason: 'error' })
await expect(run.result).resolves.toEqual({
output: [],
diagnostic: expectedFailureDiagnostic('turn', 'unknown'),
stopReason: 'error',
})
await run.dispose()
}
{
@@ -1518,7 +1812,7 @@ describe('run lifecycle and quiescence', () => {
}))
await expect(run.result).resolves.toEqual({
output: [],
diagnostic: 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval',
diagnostic: `${expectedFailureDiagnostic('turn', 'other')}\nCodex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval`,
stopReason: 'error',
})
await run.dispose()
@@ -1538,7 +1832,7 @@ describe('run lifecycle and quiescence', () => {
})
await expect(run.result).resolves.toEqual({
output: [],
diagnostic: 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval',
diagnostic: `${expectedFailureDiagnostic('turn', 'badRequest')}\nCodex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval`,
stopReason: 'error',
})
await run.dispose()
@@ -1560,7 +1854,7 @@ describe('run lifecycle and quiescence', () => {
}))
await expect(run.result).resolves.toEqual({
output: [],
diagnostic: 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval',
diagnostic: `${expectedFailureDiagnostic('turn', 'badRequest')}\nCodex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval`,
stopReason: 'error',
})
expect(Buffer.concat(hostStderrWrite.chunks).toString()).toContain('SECRET_TOKEN')
@@ -1583,7 +1877,7 @@ describe('run lifecycle and quiescence', () => {
}))
await expect(run.result).resolves.toEqual({
output: [],
diagnostic: 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval',
diagnostic: `${expectedFailureDiagnostic('turn', 'badRequest')}\nCodex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval`,
stopReason: 'error',
})
await run.dispose()
@@ -1606,13 +1900,129 @@ describe('run lifecycle and quiescence', () => {
)).rejects.toThrow('aborted before app-server startup')
expect(spawn).not.toHaveBeenCalled()
const spawnFailure = startCodexRun(request(), {
cwd: process.cwd(),
permissionMode: DEFAULT_CODEX_PERMISSION_MODE,
env: {},
disposeGraceMs: 10,
spawn: () => { throw new Error('SECRET_TOKEN spawn failure') },
})
await expect(spawnFailure)
.rejects.toThrow(expectedFailureDiagnostic('initialize', 'unknown'))
await expect(spawnFailure).rejects.not.toThrow('SECRET_TOKEN')
const asyncSpawnFailureChild = fakeChild({
pid: -1,
doneError: new Error('SECRET_TOKEN async spawn failure'),
})
const asyncSpawnFailure = startCodexRun(
request(),
runSpec(asyncSpawnFailureChild),
)
await expect(asyncSpawnFailure)
.rejects.toThrow(expectedFailureDiagnostic('initialize', 'unknown'))
await expect(asyncSpawnFailure).rejects.not.toThrow('SECRET_TOKEN')
expect(asyncSpawnFailureChild.terminate).not.toHaveBeenCalled()
const child = fakeChild()
const starting = startCodexRun(request(), runSpec(child))
const initialize = await child.peer.nextMethod('initialize')
child.peer.respond(initialize, null)
await expect(starting).rejects.toThrow('invalid initialize response')
await expect(starting)
.rejects.toThrow(expectedFailureDiagnostic('initialize', 'unknown'))
await expect(starting).rejects.not.toThrow('invalid initialize response')
expect(child.terminate).toHaveBeenCalledTimes(1)
const cleanupFailureChild = fakeChild({
waitForExitError: new Error('SECRET_TOKEN wait failure'),
})
const cleanupFailure = startCodexRun(
request(),
runSpec(cleanupFailureChild),
)
const cleanupFailureInitialize = await cleanupFailureChild.peer
.nextMethod('initialize')
cleanupFailureChild.peer.respond(cleanupFailureInitialize, null)
const cleanupError: unknown = await cleanupFailure.then(
() => undefined,
(error: unknown) => error,
)
expect(cleanupError).toBeInstanceOf(AggregateError)
expect(String(cleanupError)).toContain(
expectedFailureDiagnostic('initialize', 'unknown'),
)
expect(String(cleanupError)).toContain(expectedFailureDiagnostic(
'teardown',
'unknown',
{ outcome: { exitCode: 0, signal: null } },
))
expect(String(cleanupError)).not.toContain('SECRET_TOKEN')
const cleanupRaceAbort = new AbortController()
const cleanupRaceChild = fakeChild({ exitOnTerminate: false })
const cleanupRace = startCodexRun(
request(undefined, cleanupRaceAbort.signal),
runSpec(cleanupRaceChild),
)
const cleanupRaceInitialize = await cleanupRaceChild.peer.nextMethod('initialize')
cleanupRaceChild.peer.respond(cleanupRaceInitialize, null)
await nextTask()
cleanupRaceAbort.abort(new Error('cancelled during cleanup'))
cleanupRaceChild.settle()
await expect(cleanupRace)
.rejects.toThrow('aborted before run publication')
const threadChild = fakeChild()
const threadStarting = startCodexRun(request(), runSpec(threadChild))
const threadInitialize = await threadChild.peer.nextMethod('initialize')
threadChild.peer.respond(threadInitialize, { userAgent: 'codex-cli 0.147.0' })
await threadChild.peer.nextMethod('initialized')
const invalidThread = await threadChild.peer.nextMethod('thread/start')
threadChild.peer.respond(invalidThread, { thread: { id: '', ephemeral: true } })
await expect(threadStarting)
.rejects.toThrow(expectedFailureDiagnostic('thread-start', 'unknown'))
await expect(threadStarting).rejects.not.toThrow('thread/start thread id')
const exitedThreadChild = fakeChild({ exitOnTerminate: false })
const exitedThreadStarting = startCodexRun(
request(),
runSpec(exitedThreadChild),
)
const exitedThreadInitialize = await exitedThreadChild.peer.nextMethod('initialize')
exitedThreadChild.peer.respond(exitedThreadInitialize, {
userAgent: 'codex-cli 0.147.0',
})
await exitedThreadChild.peer.nextMethod('initialized')
await exitedThreadChild.peer.nextMethod('thread/start')
exitedThreadChild.settle({ exitCode: null, signal: 'SIGABRT' })
await expect(exitedThreadStarting).rejects.toThrow(expectedFailureDiagnostic(
'thread-start',
'unknown',
{ outcome: { exitCode: null, signal: 'SIGABRT' } },
))
const eofBeforeCloseChild = fakeChild({ exitOnTerminate: false })
const eofBeforeCloseStarting = startCodexRun(
request(),
runSpec(eofBeforeCloseChild),
)
const eofBeforeCloseInitialize = await eofBeforeCloseChild.peer
.nextMethod('initialize')
eofBeforeCloseChild.peer.respond(eofBeforeCloseInitialize, {
userAgent: 'codex-cli 0.147.0',
})
await eofBeforeCloseChild.peer.nextMethod('initialized')
await eofBeforeCloseChild.peer.nextMethod('thread/start')
eofBeforeCloseChild.fromChild.emit('end')
setImmediate(() => {
eofBeforeCloseChild.settle({ exitCode: 23, signal: null })
})
await expect(eofBeforeCloseStarting).rejects.toThrow(
expectedFailureDiagnostic('thread-start', 'unknown', {
outcome: { exitCode: 23, signal: null },
}),
)
const stderrChild = fakeChild()
const stderrStarting = startCodexRun(request(), runSpec(stderrChild))
const stderrInitialize = await stderrChild.peer.nextMethod('initialize')
@@ -1657,60 +2067,6 @@ describe('run lifecycle and quiescence', () => {
expect(child.terminate).toHaveBeenCalledTimes(1)
})
it('rolls back a subprocess done rejection during startup', async () => {
const child = fakeChild({ doneError: new Error('spawn observer failed') })
const error: unknown = await startCodexRun(request(), runSpec(child)).then(
() => undefined,
(failure: unknown) => failure,
)
expect(error).toBeInstanceOf(AggregateError)
if (!(error instanceof AggregateError)) {
throw new Error('expected startup and rollback failures')
}
expect(error.errors).toEqual([
expect.objectContaining({ message: 'spawn observer failed' }),
expect.objectContaining({ message: 'spawn observer failed' }),
])
expect(child.terminate).toHaveBeenCalledTimes(1)
})
it('surfaces only the wrapper missing-payload diagnostic during startup', async () => {
const child = fakeChild()
child.setStderr([
`credential-like unrelated stderr ${'x'.repeat(16 * 1024)}`,
'Error: Missing optional dependency @openai/codex-linux-x64. '
+ 'Reinstall Codex: pnpm add -g @openai/codex@latest',
].join('\n'))
const starting = startCodexRun(request(), runSpec(child))
child.settle({ exitCode: 1, signal: null })
const error: unknown = await starting.then(
() => undefined,
(failure: unknown) => failure,
)
expect(error).toBeInstanceOf(Error)
if (!(error instanceof Error)) throw new Error('expected startup failure')
expect(error.message).toContain('Missing optional dependency @openai/codex-linux-x64')
expect(error.message).not.toContain('credential-like unrelated stderr')
expect(error.message).not.toContain('Reinstall Codex')
expect(error.message).not.toContain('pnpm add -g')
expect(child.terminate).toHaveBeenCalledTimes(1)
})
it('waits for process settlement before sampling the missing-payload diagnostic', async () => {
const child = fakeChild({ exitOnTerminate: false })
const starting = startCodexRun(request(), runSpec(child))
child.fromChild.end()
await vi.waitFor(() => { expect(child.terminate).toHaveBeenCalledTimes(1) })
child.setStderr('Error: Missing optional dependency @openai/codex-linux-x64.')
child.settle({ exitCode: 1, signal: null })
await expect(starting).rejects.toThrow(
'Missing optional dependency @openai/codex-linux-x64',
)
})
it('keeps overlapping runs isolated', async () => {
const initialStderrListeners = {
error: process.stderr.listenerCount('error'),
@@ -1783,12 +2139,12 @@ describe('run lifecycle and quiescence', () => {
}))
await expect(first.run.result).resolves.toEqual({
output: [],
diagnostic: 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval',
diagnostic: `${expectedFailureDiagnostic('turn', 'other')}\nCodex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval`,
stopReason: 'error',
})
await expect(second.run.result).resolves.toEqual({
output: [],
diagnostic: 'Codex unattended decision (mode: dangerously-bypass-approvals-and-sandbox; request: MCP elicitation; decision: declined): the provider does not collect interactive MCP input',
diagnostic: `${expectedFailureDiagnostic('turn', 'other')}\nCodex unattended decision (mode: dangerously-bypass-approvals-and-sandbox; request: MCP elicitation; decision: declined): the provider does not collect interactive MCP input`,
stopReason: 'error',
})
await Promise.all([first.run.dispose(), second.run.dispose()])
@@ -1810,6 +2166,41 @@ describe('run lifecycle and quiescence', () => {
permissionMode: 'approve-for-me',
disposeGraceMs: 25,
})
const invalidCwdParent = {
id: 'parent-with-invalid-cwd',
session: { header: { cwd: 'relative/SECRET_TOKEN' } },
} as unknown as Agent
const invalidCwdError: unknown = await ctx.subagents.start('codex-diagnostic', {
prompt: [{ type: 'text', text: 'task' }],
parent: invalidCwdParent,
signal: new AbortController().signal,
}).then(
() => undefined,
(error: unknown) => error,
)
expect(invalidCwdError).toBeInstanceOf(Error)
if (!(invalidCwdError instanceof Error)) {
throw new Error('expected safe invalid-cwd failure')
}
expect(invalidCwdError.message).toContain(
expectedFailureDiagnostic('initialize', 'unknown'),
)
expect(invalidCwdError.message).not.toContain('relative/SECRET_TOKEN')
expect(invalidCwdError.cause).toBeInstanceOf(Error)
expect((invalidCwdError.cause as Error).message)
.toContain('relative/SECRET_TOKEN')
expect(spawn).not.toHaveBeenCalled()
const invalidCwdAbort = new AbortController()
invalidCwdAbort.abort(new Error('cancel invalid cwd startup'))
await expect(ctx.subagents.start('codex-diagnostic', {
prompt: [{ type: 'text', text: 'task' }],
parent: invalidCwdParent,
signal: invalidCwdAbort.signal,
})).rejects.toThrow('aborted before app-server startup')
expect(spawn).not.toHaveBeenCalled()
const starting = ctx.subagents.start('codex-diagnostic', {
prompt: [{ type: 'text', text: 'task' }],
parent: fakeParent,
@@ -1848,17 +2239,18 @@ describe('run lifecycle and quiescence', () => {
}))
await expect(run.result).resolves.toEqual({
output: [],
diagnostic: 'Codex unattended decision (mode: approve-for-me; request: command approval; decision: cancelled): the provider does not grant interactive approval',
diagnostic: `${expectedFailureDiagnostic('turn', 'other')}\nCodex unattended decision (mode: approve-for-me; request: command approval; decision: cancelled): the provider does not grant interactive approval`,
stopReason: 'error',
})
expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
argv: codexAppServerArgv(),
env: { OPENAI_API_KEY: 'fake' },
graceMs: 25,
cwd: process.cwd(),
}))
expect(warnings).toEqual([
expect.stringContaining('subagent-codex "codex-diagnostic": child run failed (error): subagent-codex: Codex turn ended with status failed: error'),
expect.stringContaining(
`subagent-codex "codex-diagnostic": child run failed (error): subagent-codex: ${expectedFailureDiagnostic('turn', 'other')}`,
),
])
expect(warnings.join('\n')).not.toContain('SECRET_TOKEN')
expect(warnings.join('\n')).not.toContain('/private/secret.txt')
@@ -1915,20 +2307,37 @@ describe('disposeCodexChild', () => {
expect(child.waitForExit).not.toHaveBeenCalled()
})
it('reports direct-child observer failure and accepts absent stdin', async () => {
{
const child = fakeChild({
doneError: new Error('close observer failed'),
})
const wire = defaultWire(child)
await expect(disposeCodexChild(wire, child.handle))
.rejects.toThrow('close observer failed')
}
{
const child = fakeChild()
const handle = { ...child.handle, stdin: undefined }
const wire = defaultWire(child)
await expect(disposeCodexChild(wire, handle)).resolves.toBeUndefined()
}
it('reports tree-wait failure with safe teardown facts', async () => {
const child = fakeChild({
waitForExitError: new Error('SECRET_TOKEN wait failure'),
})
const wire = defaultWire(child)
const disposal = disposeCodexChild(wire, child.handle)
await expect(disposal).rejects.toThrow(expectedFailureDiagnostic(
'teardown',
'unknown',
{ outcome: { exitCode: 0, signal: null } },
))
await expect(disposal).rejects.not.toThrow('SECRET_TOKEN')
})
it('does not wait for a pending process outcome after tree observation fails', async () => {
const child = fakeChild({
exitOnTerminate: false,
waitForExitError: new Error('SECRET_TOKEN wait failure'),
})
const wire = defaultWire(child)
let disposalError: unknown
const disposal = disposeCodexChild(wire, child.handle).catch(
(error: unknown) => { disposalError = error },
)
await nextTask()
expect(disposalError).toBeInstanceOf(Error)
expect(String(disposalError)).toContain(
expectedFailureDiagnostic('teardown', 'unknown'),
)
expect(String(disposalError)).not.toContain('SECRET_TOKEN')
child.settle()
await disposal
})
})
+2 -2
View File
@@ -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/subagent/subagent/README.md
README.md: 99b28fb7fc62473fa02fef4f3977f29e9935edaa
README.zh.md: 6970c8f21424cb73d170602c52fb98641d5d712d
README.md: e84a6b486253e81ccf7e7df12c4149e6df4ed9f2
README.zh.md: 4ca619a9f402f3c6b4f648adf350e7ed7a568799
+1 -1
View File
@@ -65,7 +65,7 @@ Both in-process delegation paths fix the child's permission scope at the delegat
`provider.start(request): Promise<SubagentRun>` is the ownership-transfer boundary; the delegation tool also uses it inside its one-shot Task-backed background path. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce unpublished resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path; remaining prompt and turn work belongs to `SubagentRun.result`.
`SubagentRun.result` resolves to `{ output, structured?, diagnostic?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. A provider may add a safe `diagnostic` to a non-completed result after removing tool inputs, file contents, environment values, credentials, and raw protocol payloads and limiting the complete text to 4096 UTF-8 bytes. The field is not assistant output: consumers present it separately, and it does not enter `subagent/end.lastAssistantMessage`. `dispose()` is idempotent, cancels remaining work, and waits for both result settlement and child-resource quiescence. A result rejection remains on `result`; `dispose()` rejects only for an independent resource-release failure. `output` and the `subagent/end` event's `lastAssistantMessage` use the exported `AssistantOutputFold`/`finalAssistantOutput` helpers to select the child's last non-empty assistant message, or its accumulated assistant text when no such message exists. `output` is `[]` and the event field is absent when the child produced neither ([`SubagentResult`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) owns the terminal result contract).
`SubagentRun.result` resolves to `{ output, structured?, diagnostic?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. A provider may add a safe `diagnostic` to a non-completed result after removing tool inputs, file contents, environment values, credentials, and raw protocol payloads and limiting the complete text to 4096 UTF-8 bytes. The common result type does not define provider categories or lifecycle stages: an out-of-process provider may derive fixed display text from its version-pinned structured product facts and an observed process outcome, while consumers render that text without parsing it. The field is not assistant output: consumers present it separately, and it does not enter `subagent/end.lastAssistantMessage`. `dispose()` is idempotent, cancels remaining work, and waits for both result settlement and child-resource quiescence. A result rejection remains on `result`; `dispose()` rejects only for an independent resource-release failure. `output` and the `subagent/end` event's `lastAssistantMessage` use the exported `AssistantOutputFold`/`finalAssistantOutput` helpers to select the child's last non-empty assistant message, or its accumulated assistant text when no such message exists. `output` is `[]` and the event field is absent when the child produced neither ([`SubagentResult`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) owns the terminal result contract).
A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, records `request.parent.session.id` in the child's `parentSession` header, and appends the resolved descriptor inside its initial turn. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`; without a local child session, their one-shot runs are not part of trace-backed enumeration.
+1 -1
View File
@@ -65,7 +65,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
`provider.start(request): Promise<SubagentRun>` 是所有权转移边界;委派工具也会在其由 Task 支撑的一次性后台路径中使用它。兑现前,提供方拥有设置过程,并且在任何失败路径上都必须取消、回滚并使尚未发布的资源完全停稳。兑现后,run 的所有权转移给调用方;调用方必须在每条路径上调用 `dispose()`。剩余提示词和轮次工作属于 `SubagentRun.result`
`SubagentRun.result` 兑现为 `{ output, structured?, diagnostic?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。提供方可以为非完成结果附加安全的 `diagnostic`:它会先排除工具输入、文件内容、环境值、凭证与原始协议载荷,并把完整文本限制在 4096 个 UTF-8 字节以内。该字段不是 assistant 输出;消费方会将它分开呈现,它也不会进入 `subagent/end.lastAssistantMessage``dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。result 的拒绝只通过 `result` 本身报告;只有独立的资源释放失败,才会使 `dispose()` 被拒绝。`output``subagent/end` 事件的 `lastAssistantMessage` 使用导出的 `AssistantOutputFold``finalAssistantOutput` 辅助函数选取子 agent 最后一条非空 assistant 消息;若没有这类消息,则选取其累积的 assistant 文本。子 agent 两种输出均未产生时,`output``[]`,该事件字段缺省(终态结果约定归 [`SubagentResult`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) 所有)。
`SubagentRun.result` 兑现为 `{ output, structured?, diagnostic?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。提供方可以为非完成结果附加安全的 `diagnostic`:它会先排除工具输入、文件内容、环境值、凭证与原始协议载荷,并把完整文本限制在 4096 个 UTF-8 字节以内。共享结果类型不定义提供方类别或生命周期阶段:进程外提供方可以从锁定版本产品提供的结构化事实与已观测的进程结果派生固定展示文本,而消费方只负责原样呈现,不解析该文本。该字段不是 assistant 输出;消费方会将它分开呈现,它也不会进入 `subagent/end.lastAssistantMessage``dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。result 的拒绝只通过 `result` 本身报告;只有独立的资源释放失败,才会使 `dispose()` 被拒绝。`output``subagent/end` 事件的 `lastAssistantMessage` 使用导出的 `AssistantOutputFold``finalAssistantOutput` 辅助函数选取子 agent 最后一条非空 assistant 消息;若没有这类消息,则选取其累积的 assistant 文本。子 agent 两种输出均未产生时,`output``[]`,该事件字段缺省(终态结果约定归 [`SubagentResult`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) 所有)。
本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开准确的子 agent,把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header,并在其初始轮次内追加已解析的描述符。远程提供方则生成 parent 作用域的生命周期 id,并返回 `localAgent: undefined`;由于没有本地 child 会话,其一次性运行不会进入基于追踪的枚举结果。
+3
View File
@@ -2188,6 +2188,9 @@ importers:
'@types/react':
specifier: ~18.3.1
version: 18.3.31
'@types/react-dom':
specifier: ~18.3.0
version: 18.3.7(@types/react@18.3.31)
react:
specifier: ^18.2.0
version: 18.3.1
+1
View File
@@ -47,6 +47,7 @@
"apps/web/tests/web-search-round.e2e.ts",
"apps/web/tests/message-actions.e2e.ts",
"apps/web/tests/message-feedback.e2e.ts",
"apps/web/tests/message-feedback-layout.e2e.ts",
"apps/web/tests/markdown-images.e2e.ts",
"apps/web/tests/math-rendering.e2e.ts",
"apps/web/tests/markdown-cjk-strong.e2e.ts",