فهرست منبع

fix(web): keep spilled shell previews generic without guessing status

Tianyi Cui 1 هفته پیش
والد
کامیت
314cd13971

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md
-2026-08-23-client-derived-tool-presentation.md: 7f62e99fe152875bfd04bc11c5a1d5dea9014caa
-2026-08-23-client-derived-tool-presentation.zh.md: 433d1efb4cded651b39452716399be1ca02c102d
+2026-08-23-client-derived-tool-presentation.md: 58a8f23d717580355b852703f448c723d9c3a7ea
+2026-08-23-client-derived-tool-presentation.zh.md: 8e1a02eff57cce7967985a22c5bdcec7c18e6059

+ 3 - 3
.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md

@@ -51,7 +51,7 @@ The Host `ToolDefinition.presentCall`, `ToolDefinition.presentResult`, `ToolCall
 | Retained | the Session log format, Remote journal lifecycle, and Conversation identity/topology |
 | Retained | the existing keyed slot, Generic fallback, and Chat, Details, and Trajectory structure |
 | Forbidden | a new Client presenter service, parallel registry, or wire renderer id |
-| Forbidden | new cards, visual redesign, interaction redesign, or Code Dispatch rich-card enhancements except the separately owned terminal exception below |
+| Forbidden | new cards, visual redesign, interaction redesign, or Code Dispatch rich-card enhancements except the [nested terminal-card exception](../bug-fix/2026-09-05-nested-terminal-cards.md) |
 | Forbidden | compatibility dual-writing, version negotiation, or retention of the old `view` field |
 
 ## Terminology
@@ -310,7 +310,7 @@ The Client terminal model derives existing `TerminalBlock` props from the tool n
 | background/error `terminal_send` | Generic result |
 | Code Dispatch child | same terminal eligibility and fallback rules as a root call |
 
-Standard shell results continue parsing trailing `[exit code: N]` and `[killed by signal: X]` markers. A parsed marker is removed from the body; timeout, sandbox denial, and markers without a pill remain in the body.
+Standard shell results parse trailing `[exit code: N]` and `[killed by signal: X]` markers. A final recognized spill-policy notice selects Generic output instead: expandable in shell rows and raw in Details, because the exit marker may be displaced or omitted. A parsed marker is removed from the terminal body; timeout, sandbox denial, and markers without a pill remain in the body.
 
 Call `description` remains above the card and overrides the collapsed summary. Workdir continues handling absolute, relative, and missing values. Relative paths resolve against the Session cwd while preserving normalization for `.`, `..`, drive letters, and UNC roots.
 
@@ -634,7 +634,7 @@ An on-demand RPC would turn one page read into N network calls and would still r
 
 ### Allow presentation enhancements
 
-Bundling richer Code Dispatch cards, missing-call-head inference, or other historical presentation enhancements with the ownership change would prevent snapshots from proving equivalence. This decision rejects that coupling; the separately owned terminal exception below does not relax nonterminal child restrictions.
+Bundling richer Code Dispatch cards, missing-call-head inference, or other historical presentation enhancements with the ownership change would prevent snapshots from proving equivalence. This decision rejects that coupling; the [nested terminal-card exception](../bug-fix/2026-09-05-nested-terminal-cards.md) does not relax nonterminal child restrictions.
 
 ### Accept temporary Generic degradation
 

+ 3 - 3
.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md

@@ -51,7 +51,7 @@ Host 的 `ToolDefinition.presentCall`、`ToolDefinition.presentResult`、`ToolCa
 | 保留 | Session 日志格式、Remote journal 生命周期与 Conversation identity/topology |
 | 保留 | 现有 keyed slot、Generic fallback、Chat、Details 与 Trajectory 结构 |
 | 禁止 | 新 Client presenter service、平行 registry 或 wire renderer id |
-| 禁止 | 新卡片、视觉改版、交互改版或 Code Dispatch rich-card 增强,下述独立决策的 terminal 例外除外 |
+| 禁止 | 新卡片、视觉改版、交互改版或 Code Dispatch rich-card 增强,[嵌套 terminal 卡片例外](../bug-fix/2026-09-05-nested-terminal-cards.zh.md)除外 |
 | 禁止 | 为兼容保留双写、版本协商或旧 `view` 字段 |
 
 ## 术语
@@ -310,7 +310,7 @@ Client terminal model 从工具名称、调用参数、结果 content、error 
 | `terminal_send` background/error | Generic 结果 |
 | Code Dispatch child | 与根调用相同的 terminal 适用规则与 fallback 规则 |
 
-标准 shell 结果继续解析末尾 `[exit code: N]` 与 `[killed by signal: X]`。已解析的 marker 从正文移除;timeout、sandbox denial 与没有 pill 的 marker 留在正文。
+标准 shell 结果解析末尾 `[exit code: N]` 与 `[killed by signal: X]`。末尾已识别的 spill 策略提示会改用 Generic 输出:在 shell 行中可展开,在 Details 中显示原文,因为退出标记可能被移位或省略。已解析的 marker 从 terminal 正文移除;timeout、sandbox denial 与没有 pill 的 marker 留在正文。
 
 调用 `description` 继续显示在 card 上方并覆盖折叠摘要。workdir 继续按绝对、相对和缺失三种情况处理;相对路径基于 Session cwd,且保留 `.`、`..`、盘符与 UNC root 的归一化。
 
@@ -634,7 +634,7 @@ read 行结构、applied diff、search 分组、web sources 和有效 truncation
 
 ### 允许展示增强
 
-将更丰富的 Code Dispatch 卡片、缺失 call head 的推断或其他历史展示增强与所有权变更捆绑,会使快照无法证明对等。本决定拒绝这种捆绑;下述独立决策的 terminal 例外不放宽非 terminal 子调用限制。
+将更丰富的 Code Dispatch 卡片、缺失 call head 的推断或其他历史展示增强与所有权变更捆绑,会使快照无法证明对等。本决定拒绝这种捆绑;[嵌套 terminal 卡片例外](../bug-fix/2026-09-05-nested-terminal-cards.zh.md)不放宽非 terminal 子调用限制。
 
 ### 接受临时 Generic 退化
 

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-09-05-nested-terminal-cards.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-05-nested-terminal-cards.md
-2026-09-05-nested-terminal-cards.md: 10e0c082650fe5a937aa8fba9f6080896fe8538f
-2026-09-05-nested-terminal-cards.zh.md: 2ad94f62657b78b5eaf6fde0033e73abee07997e
+2026-09-05-nested-terminal-cards.md: 0e38414e9450be060b0b66c42c4cac8111dd5476
+2026-09-05-nested-terminal-cards.zh.md: 5f167a926771195d0f6b8596d5335aebe57af3e2

+ 4 - 0
.agents/notes/implemented/bug-fix/2026-09-05-nested-terminal-cards.md

@@ -14,12 +14,16 @@ A shell command dispatched through `run_code` carries the arguments and rendered
 
 This partially supersedes only the terminal child-card prohibition in [Client-derived tool presentation](../architecture/2026-08-23-client-derived-tool-presentation.md). That note remains active for Client presentation ownership and the diff/read/search/web child restrictions. No Host presenter, event, schema, metadata, call-tree, or model-context change is required. The metadata and execution-local value decisions in [canonical tool output](../architecture/2026-07-20-canonical-tool-output-contract.md) and [PTC typed returns](../feature/2026-07-20-ptc-typed-tool-returns.md) remain intact; metadata omission does not prohibit Client-derived terminal cards.
 
+Shell output ending in a recognized spill-policy notice uses generic output: expandable in `BashRow`, raw fallback in Details. The notice can follow or replace the exit marker, so its absence at the end does not justify a successful terminal status. The Client recognizes the final notice without changing Host output or schemas.
+
 ## Alternatives considered
 
 **Keep the blanket nested-call rejection.** Rejected because nesting does not remove the raw facts the terminal model already consumes. It hides usable shell output while the same call renders as a terminal at the root.
 
 **Enable every nested structured card.** Rejected because other card models have independent metadata requirements and child restrictions. This fix changes only terminal eligibility.
 
+**Parse exit markers around spill suffixes.** Rejected because truncation can remove the real status; conservative generic output avoids guessing success from an incomplete result.
+
 ## Consequences
 
 Rows and Details share terminal derivation for nested calls without a second renderer or presentation hint. Generic fallback and settled-persistent behavior remain separate from terminal-card eligibility. The parent-child relationship still controls tree placement, not terminal rendering.

+ 4 - 0
.agents/notes/implemented/bug-fix/2026-09-05-nested-terminal-cards.zh.md

@@ -14,12 +14,16 @@ Status: implemented
 
 本文仅部分取代 [Client 派生工具展示](../architecture/2026-08-23-client-derived-tool-presentation.zh.md)中的 terminal 子调用卡片禁令。该文继续负责 Client 展示所有权及 diff/read/search/web 子调用限制。无需更改 Host 展示转换器、事件、schema、元数据、调用树或模型上下文。[规范工具输出](../architecture/2026-07-20-canonical-tool-output-contract.zh.md)与 [PTC 类型化返回值](../feature/2026-07-20-ptc-typed-tool-returns.zh.md)中的元数据和执行期值决策保持不变;省略元数据不禁止 Client 派生 terminal 卡片。
 
+以已识别的 spill 策略提示结尾的 shell 输出使用通用展示:在 `BashRow` 中可展开,在 Details 中使用原始回退。提示可能位于退出标记之后或取代它,因此末尾缺少退出标记不能作为 terminal 成功状态的依据。Client 识别最终提示,不更改 Host 输出或 schema。
+
 ## 考虑过的替代方案
 
 **保留对嵌套调用的一律拒绝。** 不予采用,因为嵌套不会移除 terminal model 已消费的原始事实。这会隐藏可用的 shell 输出,而同一调用位于根时却可渲染为 terminal。
 
 **启用所有嵌套结构化卡片。** 不予采用,因为其他 card model 有独立的元数据要求与子调用限制。本修复只改变 terminal 适用性。
 
+**解析 spill 后缀附近的退出标记。** 不予采用,因为截断可能移除真实状态;保守的通用输出避免从不完整结果猜测成功。
+
 ## 后果
 
 行与 Details 对嵌套调用共享 terminal 派生,不增加第二个渲染器或展示提示字段。通用回退与已完成持久 shell 的行为仍独立于 terminal 卡片适用性。父子关系仍控制树中的位置,而非 terminal 渲染。

+ 3 - 0
apps/web/tests/ptc-round.e2e.ts

@@ -135,6 +135,9 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => {
 
   it.skipIf(MODE === 'record')('matches the expanded conversation aria golden with stable anchors', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-ptc-aria'))
+    const row = page.locator('[data-subcalls] [data-sample="bash"]').first()
+    await expandOwningTurnProcess(page, row)
+    if (await row.getAttribute('aria-expanded') !== 'true') await row.click()
     const snapshot = await captureExpandedTurnProcessAria(
       page,
       '[class*="centerCol"]',

+ 2 - 2
packages/client/ui-tool/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-tool/README.md
-README.md: 02c403133f7586217ed3397d9f496baedfcfb0a8
-README.zh.md: 0278e7fceca34d0e2b9334cfb33ae1a85fbdc27c
+README.md: e9c975fb40f1584149eb9200f43e25ec70379d8f
+README.zh.md: f64bc19752b97295ac80b839f56dd39f47ad2dfb

+ 1 - 1
packages/client/ui-tool/README.md

@@ -43,7 +43,7 @@ The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `blo
 
 ### Built-in views
 
-This package owns the generic fallback and the built-in shell/pwsh, read, read_image, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Running and settled foreground `bash`, `pwsh`, and `terminal_send` calls use terminal cards at the root and in Code Dispatch children, subject to the same argument, result, and error checks. Settled persistent-shell results stay generic because reset and partial-output diagnostics do not always describe one process exit status; root persistent results are expandable, while background acknowledgements remain collapsed. A successful question row pairs call questions with result answers by their stable ids and shows readable question/answer lines when expanded. A cancelled or interrupted row shows its verdict and original questions without inventing answers. Unsupported, malformed, or ambiguous inputs fall back to flattened Tool input/result text. `ui-skill` demonstrates a business-owned registration for `skill`.
+This package owns the generic fallback and the built-in shell/pwsh, read, read_image, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Running and settled foreground standard `bash`/`pwsh` and `terminal_send` calls use terminal cards at the root and in Code Dispatch children, subject to the same argument, result, and error checks. Persistent `bash`/`pwsh` calls use terminal cards only while running. Shell output ending in a recognized spill-policy notice uses expandable generic output in shell rows and generic output in Details; a displaced or omitted exit marker cannot establish success. Settled persistent-shell results stay generic because reset and partial-output diagnostics do not always describe one process exit status; root persistent results are expandable, while background acknowledgements remain collapsed. A successful question row pairs call questions with result answers by their stable ids and shows readable question/answer lines when expanded. A cancelled or interrupted row shows its verdict and original questions without inventing answers. Unsupported, malformed, or ambiguous inputs fall back to flattened Tool input/result text. `ui-skill` demonstrates a business-owned registration for `skill`.
 
 -----
 

+ 1 - 1
packages/client/ui-tool/README.zh.md

@@ -43,7 +43,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`
 
 ### 内置视图
 
-本包拥有 generic fallback,以及 shell/pwsh、read、read_image、write/edit、running `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 Code Dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。运行中与已完成的前台 `bash`、`pwsh` 和 `terminal_send` 调用,无论位于根还是 Code Dispatch 子调用中,都在通过相同的参数、结果和错误检查后使用 terminal 卡片。已完成的持久 shell 结果保持 generic 展示,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;根调用的持久 shell 结果可展开,后台启动回执则保持折叠。成功的问题行按稳定 id 配对调用中的问题与结果中的回答,展开后显示可读的问答行。已取消或已中断的问题行显示其裁决与原始问题,不虚构回答。不受支持、格式错误或含糊的输入回退为压平的工具输入/结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。
+本包拥有 generic fallback,以及 shell/pwsh、read、read_image、write/edit、running `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 Code Dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。运行中与已完成的前台标准 `bash`/`pwsh` 和 `terminal_send` 调用,无论位于根还是 Code Dispatch 子调用中,都在通过相同的参数、结果和错误检查后使用 terminal 卡片。持久 `bash`/`pwsh` 调用仅在运行中使用 terminal 卡片。以已识别的 spill 策略提示结尾的 shell 输出,在 shell 行中使用可展开的 generic 输出,在 Details 中使用 generic 输出;位置被改变或被省略的退出标记无法证明成功。已完成的持久 shell 结果保持 generic 展示,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;根调用的持久 shell 结果可展开,后台启动回执则保持折叠。成功的问题行按稳定 id 配对调用中的问题与结果中的回答,展开后显示可读的问答行。已取消或已中断的问题行显示其裁决与原始问题,不虚构回答。不受支持、格式错误或含糊的输入回退为压平的工具输入/结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。
 
 -----
 

+ 21 - 3
packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts

@@ -219,6 +219,24 @@ export function isSettledPersistentShellCall(block: ToolCallBlock): boolean {
   return shellCall(parsed.name, parsed.args)?.persistent === true
 }
 
+/**
+ * Identify a settled foreground shell preview whose spill footer can hide the exit marker.
+ * @param block - running or settled Tool block.
+ * @returns whether the shell output must remain generic without an inferred exit status.
+ */
+export function isSpilledShellCall(block: ToolCallBlock): boolean {
+  if (!('kind' in block)) return false
+  const parsed = parsedToolCall(block)
+  if (parsed === null) return false
+  const call = shellCall(parsed.name, parsed.args)
+  if (call === null || call.background) return false
+  const output = singleResultText(block)
+  // spill-policy appends this footer after a bounded preview; the process
+  // status can be displaced or omitted, so absence cannot imply exit zero.
+  return output !== undefined
+    && /(?:^|\n\n)\(Omitted \d+ bytes\. Full formatted result stored at: [\s\S]+\)$/.test(output)
+}
+
 interface TerminalSendCall {
   kind: 'terminal-send'
   text: string
@@ -257,8 +275,8 @@ function parseExitStatus(text: string): { output: string; exitCode?: number; sig
 /**
  * Derive terminal props for supported shell and terminal-send calls, including
  * nested Code Dispatch calls. Standard shell results parse their final status
- * marker; persistent shell results, background calls, errors, and malformed
- * input use the generic path. {@link isSettledPersistentShellCall} lets that generic
+ * marker; persistent shell results, spill previews, background calls, errors,
+ * and malformed input use the generic path. {@link isSettledPersistentShellCall} lets that generic
  * persistent result remain expandable without inventing one process status.
  * @param block - running or settled Tool block.
  * @param sessionCwd - session workspace root used to resolve workdir.
@@ -289,7 +307,7 @@ export function terminalCardModel(
       },
     }
   }
-  if (block.isError || (call.kind === 'shell' && call.persistent)) return null
+  if (block.isError || (call.kind === 'shell' && call.persistent) || isSpilledShellCall(block)) return null
   const output = singleResultText(block)
   if (output === undefined) return null
   const status = call.kind === 'terminal-send' ? { output } : parseExitStatus(output)

+ 4 - 4
packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx

@@ -8,6 +8,7 @@ import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ToolCallViewProps } from '../../contract/slots.ts'
 import {
   isSettledPersistentShellCall,
+  isSpilledShellCall,
   localizeTerminalCardModel,
   terminalBlockLabels,
   terminalCardModel,
@@ -53,11 +54,10 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
     : model.state
   const status = stateStatus(state, t)
   const [expanded, setExpanded] = useState(false)
-  // Execution failures and persistent-shell results have no terminal card.
-  // Keep their recorded args and complete output reachable through the generic
-  // body; background acknowledgements and malformed calls remain collapsed.
+  // Failures, persistent-shell results, and spill previews use a generic body;
+  // background acknowledgements and malformed calls remain collapsed.
   const genericBody = terminal === null
-    && (model.state === 'error' || isSettledPersistentShellCall(block))
+    && (model.state === 'error' || isSettledPersistentShellCall(block) || isSpilledShellCall(block))
     && (model.bodyRaw !== null || model.output !== null)
   const expandable = terminal !== null || genericBody
   const open = expanded && expandable

+ 36 - 0
packages/client/ui-tool/tests/terminal-card.client.spec.tsx

@@ -225,6 +225,30 @@ describe('terminalCardModel', () => {
     expect(terminalCardModel(running({ parentCallId: 'parent' }))).toEqual(terminalCardModel(running()))
   })
 
+  it.each(['bash', 'pwsh'])('keeps nested persistent %s running cards and settled generic results', (name) => {
+    const argsRaw = JSON.stringify({ command: 'pwd' })
+    expect(terminalCardModel(running({ name, argsRaw, parentCallId: 'parent' })))
+      .toEqual(terminalCardModel(running({ name, argsRaw })))
+    expect(terminalCardModel(settled({ call: { name, argsRaw }, parentCallId: 'parent' }))).toBeNull()
+  })
+
+  it.each(['bash', 'pwsh'])('does not infer %s exit status from a spilled preview', (name) => {
+    const notice = '(Omitted 50000 bytes. Full formatted result stored at: /spill/output.txt. Read the file.)'
+    for (const parentCallId of [undefined, 'parent']) {
+      for (const preview of ['failed\n[exit code: 7]', 'killed\n[killed by signal: SIGTERM]', 'partial', '']) {
+        expect(terminalCardModel(settled({
+          ...parentCallId === undefined ? {} : { parentCallId },
+          call: { name, argsRaw: ARGS },
+          content: [{ type: 'text', text: preview === '' ? notice : `${preview}\n\n${notice}` }],
+        }))).toBeNull()
+      }
+    }
+    expect(terminalCardModel(settled({
+      call: { name, argsRaw: ARGS },
+      content: [{ type: 'text', text: `${notice}\nordinary output` }],
+    }))).not.toBeNull()
+  })
+
   it('returns null for background, errors, malformed args, unsupported tools, and non-text results', () => {
     expect(terminalCardModel(running({ argsRaw: shellArgs({ run_in_background: true }) }))).toBeNull()
     expect(terminalCardModel(settled({ isError: true }))).toBeNull()
@@ -466,6 +490,18 @@ describe('BashRow terminal card', () => {
       .toBe('a.ts  b.ts\nc.ts  d.ts\n')
   })
 
+  it('expands a spilled child result without a successful terminal indicator', () => {
+    const output = 'failed\n[exit code: 7]\n\n(Omitted 50000 bytes. Full formatted result stored at: /spill/output.txt. Read the file.)'
+    const view = render(<BashRow {...rowProps(settled({
+      parentCallId: 'parent', content: [{ type: 'text', text: output }],
+    }))} />)
+    const row = view.container.querySelector('[data-sample="bash"]')!
+    expect(row.getAttribute('role')).toBe('button')
+    fireEvent.click(row)
+    expect(view.container.querySelector('[data-terminal]')).toBeNull()
+    expect(view.container.querySelectorAll('[class*="_ioText_"]')[1]?.textContent).toBe(output)
+  })
+
   it('a non-terminal bash call (background start) renders the summary row alone', () => {
     const view = render(<BashRow {...rowProps(settled({
       call: { name: 'bash', argsRaw: shellArgs({ command: 'sleep 30', description: 'Wait', run_in_background: true }) },