Bläddra i källkod

fix(spill): share persisted notice formatting with client recognition

Tianyi Cui 1 månad sedan
förälder
incheckning
b25d4faaaa

+ 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: 0e38414e9450be060b0b66c42c4cac8111dd5476
-2026-09-05-nested-terminal-cards.zh.md: 5f167a926771195d0f6b8596d5335aebe57af3e2
+2026-09-05-nested-terminal-cards.md: b369aef20b0b5fbb338e40affd5fd2800b525bcb
+2026-09-05-nested-terminal-cards.zh.md: bf97fffa2a8d92562e982d498d17b830e1e25f48

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

@@ -14,7 +14,7 @@ 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.
+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 browser-safe `@deepseek-ai/dsh-spill-policy/notice` entry owns the text convention: the producer calls `formatSpillNotice(omitted, ref)` and the Client calls `hasSpillNotice(text)`. Both share delimiters, and omission validation reuses `describeOmitted` rather than duplicating its prose. The formatter preserves the persisted spelling byte-for-byte; existing Session result bytes stay untouched, with no Session format change or migration.
 
 ## Alternatives considered
 
@@ -24,10 +24,12 @@ Shell output ending in a recognized spill-policy notice uses generic output: exp
 
 **Parse exit markers around spill suffixes.** Rejected because truncation can remove the real status; conservative generic output avoids guessing success from an incomplete result.
 
+**Maintain a separate UI notice regex.** Rejected because it duplicates the producer's text convention and can drift from persisted output. The shared browser-safe owner keeps formatting and recognition together without loading the Host plugin in the browser.
+
 ## 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.
+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. Text recognition cannot authenticate output: a tool can print the same notice. A match selects conservative generic presentation, not proof of spill provenance or process status.
 
 ## Verification
 
-The [terminal card specs](../../../../packages/client/ui-tool/tests/terminal-card.client.spec.tsx) cover root/child eligibility, running and settled Details, and fallback cases. The [assembled Code Dispatch specs](../../../../packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx) cover nested terminal rendering through the conversation tree. Browser replay owns the visible nested-card change; nonterminal child behavior remains outside this fix.
+The [terminal card specs](../../../../packages/client/ui-tool/tests/terminal-card.client.spec.tsx) cover root/child eligibility, running and settled Details, and fallback cases. The [assembled Code Dispatch specs](../../../../packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx) cover nested terminal rendering through the conversation tree. The [notice specs](../../../../packages/spill/spill-policy/tests/notice.spec.ts) pin the historical spelling with a literal fixture independent of the formatter. The [spill-policy-to-UI specs](../../../../packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts) exercise actual root and PTC spill production, unchanged full text and programmatic values, byte caps, notice-only output, and terminal fallback. Browser replay owns the visible nested-card change; nonterminal child behavior remains outside this fix.

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

@@ -14,7 +14,7 @@ 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。
+以已识别的 spill 策略提示结尾的 shell 输出使用通用展示:在 `BashRow` 中可展开,在 Details 中使用原始回退。提示可能位于退出标记之后或取代它,因此末尾缺少退出标记不能作为 terminal 成功状态的依据。浏览器安全入口 `@deepseek-ai/dsh-spill-policy/notice` 负责文本约定:生产方调用 `formatSpillNotice(omitted, ref)`,Client 调用 `hasSpillNotice(text)`。两者共用分隔符,省略信息校验复用 `describeOmitted`,不复制其文案。格式化函数逐字节保留持久化拼写;现有 Session 结果字节保持不变,不更改 Session 格式,也不执行迁移。
 
 ## 考虑过的替代方案
 
@@ -24,10 +24,12 @@ Status: implemented
 
 **解析 spill 后缀附近的退出标记。** 不予采用,因为截断可能移除真实状态;保守的通用输出避免从不完整结果猜测成功。
 
+**维护独立的 UI 通知正则表达式。** 不予采用,因为它复制生产方的文本约定,可能与持久化输出偏离。共享的浏览器安全模块统一负责格式化与识别,无需在浏览器中加载 Host 插件。
+
 ## 后果
 
-行与 Details 对嵌套调用共享 terminal 派生,不增加第二个渲染器或展示提示字段。通用回退与已完成持久 shell 的行为仍独立于 terminal 卡片适用性。父子关系仍控制树中的位置,而非 terminal 渲染。
+行与 Details 对嵌套调用共享 terminal 派生,不增加第二个渲染器或展示提示字段。通用回退与已完成持久 shell 的行为仍独立于 terminal 卡片适用性。父子关系仍控制树中的位置,而非 terminal 渲染。文本识别无法认证输出来源:工具也能打印相同的提示。匹配结果只选择保守的通用展示,不能证明 spill 来源或进程状态。
 
 ## 验证
 
-[Terminal 卡片测试](../../../../packages/client/ui-tool/tests/terminal-card.client.spec.tsx)覆盖根/子调用适用性、运行中与已完成的 Details 以及回退情况。[组装后的 Code Dispatch 测试](../../../../packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx)覆盖经对话树渲染的嵌套 terminal。浏览器回放负责验证可见的嵌套卡片变化;非 terminal 子调用行为不属于本修复。
+[Terminal 卡片测试](../../../../packages/client/ui-tool/tests/terminal-card.client.spec.tsx)覆盖根/子调用适用性、运行中与已完成的 Details 以及回退情况。[组装后的 Code Dispatch 测试](../../../../packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx)覆盖经对话树渲染的嵌套 terminal。[通知测试](../../../../packages/spill/spill-policy/tests/notice.spec.ts)使用独立于格式化函数的字面量 fixture(测试前置数据)固定历史拼写。[spill-policy 到 UI 的测试](../../../../packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts)覆盖真实的根调用与 PTC spill 生成、保持不变的完整文本和程序化值、字节上限、仅含通知的输出以及 terminal 回退。浏览器回放负责验证可见的嵌套卡片变化;非 terminal 子调用行为不属于本修复。

+ 2 - 2
docs/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: 72d7423f308a60454eaab62a288f3617fb5b91ae
-config-catalog.zh.md: d3025a25bd808fe493ea0ae8ed93c8611ece944e
+config-catalog.md: 8cca25f5feb63ad3157253e65cf10e78d1cd4707
+config-catalog.zh.md: f7c4adf580cf11f05fbe209cf0ffeb4dfb50b45a

+ 1 - 1
docs/config-catalog.md

@@ -2192,7 +2192,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/spill/spill-policy/src/index.ts:60`](../packages/spill/spill-policy/src/index.ts)
+Source: [`packages/spill/spill-policy/src/index.ts:61`](../packages/spill/spill-policy/src/index.ts)
 
 <a id="deepseek-aidsh-storage-domain"></a>
 

+ 1 - 1
docs/config-catalog.zh.md

@@ -2194,7 +2194,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/spill/spill-policy/src/index.ts:60`](../packages/spill/spill-policy/src/index.ts)
+来源:[`packages/spill/spill-policy/src/index.ts:61`](../packages/spill/spill-policy/src/index.ts)
 
 <a id="deepseek-aidsh-storage-domain"></a>
 

+ 1 - 1
packages/client/tsdown.client.ts

@@ -58,7 +58,7 @@ function styleInjectionModule(
  * Everything else under @deepseek-ai/* is either a module-table entry
  * (external) or a leak the purity gate rejects.
  */
-export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-agent-presets\/display$)/
+export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-agent-presets\/display$|@deepseek-ai\/dsh-spill-policy\/notice$)/
 
 /**
  * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below

+ 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: e9c975fb40f1584149eb9200f43e25ec70379d8f
-README.zh.md: f64bc19752b97295ac80b839f56dd39f47ad2dfb
+README.md: e0b49a3b582f5f1e6e9aa19e0b026dedb9ba2a18
+README.zh.md: 032f048f2501923c4c9f010b76afe27d98fe7889

+ 2 - 0
packages/client/ui-tool/README.md

@@ -63,6 +63,8 @@ The package realizes one dispatch rule: atomic Tool views are keyed by wire Tool
 
 
 The package fills `conversation.details.tool` with `ToolDetails`. Row and Details renderers share one pure card model for each terminal, read, diff, search, and web card; the image card is row-only because its gallery renders through the tool-owned `tool.call.images` slot the details panel does not declare. These models validate raw call arguments, result content, failure state, persisted metadata, and Session path facts. Diff, read, search, and web models also reject Code Dispatch children; terminal eligibility does not depend on `parentCallId`. Unsupported or malformed inputs use flattened Tool result text. Card-specific limits and fallback rules for the terminal, diff, read, search, and web cards remain in [the ui-primitives README](../ui-primitives/README.md); the image card's model in this package carries its own fallback rules.
+
+The terminal model uses `hasSpillNotice` from the browser-safe `@deepseek-ai/dsh-spill-policy/notice` entry, not an independent UI pattern. The [spill-policy README](../../spill/spill-policy/README.md#shared-notice-ownership) owns notice formatting and recognition. This check conservatively selects generic output; matching text cannot authenticate its source, and replay leaves recorded result bytes untouched.
 </details>
 
 -----

+ 2 - 0
packages/client/ui-tool/README.zh.md

@@ -63,6 +63,8 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`
 
 
 本包通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与 Details renderer 分别为 terminal、read、diff、search 和 web 卡片复用同一个纯 card model;image 卡片仅属于行,因为其图库经由工具自有 `tool.call.images` 槽位渲染,而 details 面板不声明该槽位。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata 与 Session 路径事实。diff、read、search 和 web model 还会拒绝 Code Dispatch 子调用;terminal 适用性不依赖 `parentCallId`。不受支持或格式错误的输入使用压平的工具结果文本。terminal、diff、read、search 与 web 卡片的上限与 fallback 规则仍由 [ui-primitives README](../ui-primitives/README.zh.md) 负责;image 卡片的 fallback 规则由本包内的 card model 自行承载。
+
+terminal model 使用浏览器安全入口 `@deepseek-ai/dsh-spill-policy/notice` 的 `hasSpillNotice`,而非独立的 UI 匹配规则。[spill-policy README](../../spill/spill-policy/README.zh.md#shared-notice-ownership) 负责通知的格式化与识别。该检查保守地选择通用输出;匹配文本不能认证其来源,回放也不改变已记录的结果字节。
 </details>
 
 -----

+ 8 - 0
packages/client/ui-tool/package.json

@@ -48,6 +48,14 @@
     "@deepseek-ai/cordis": "workspace:^"
   },
   "devDependencies": {
+    "@deepseek-ai/dsh-spill-policy": "workspace:^",
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-system-prompt": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^",
+    "@deepseek-ai/dsh-spill": "workspace:^",
+    "@deepseek-ai/dsh-code-runtime-worker-thread": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",

+ 2 - 4
packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts

@@ -2,6 +2,7 @@
 import type { TerminalBlockLabels, TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
 import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
+import { hasSpillNotice } from '@deepseek-ai/dsh-spill-policy/notice'
 import type { ToolCallBlock } from './tool-call-model.ts'
 import { parsedToolCall, singleResultText, validEscalationFields } from './raw-tool-call.ts'
 
@@ -231,10 +232,7 @@ export function isSpilledShellCall(block: ToolCallBlock): boolean {
   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)
+  return output !== undefined && hasSpillNotice(output)
 }
 
 interface TerminalSendCall {

+ 148 - 0
packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts

@@ -0,0 +1,148 @@
+/** Producer-to-UI regression for shell results bounded by the real spill policy. */
+import { Context } from '@deepseek-ai/cordis'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
+import { WorkerThreadCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker-thread'
+import { ToolCallId } from '@deepseek-ai/dsh-llm'
+import { Session, SessionId } from '@deepseek-ai/dsh-session'
+import { SpillLocator, SpillStore, type SaveTextSpill, type SpillRef } from '@deepseek-ai/dsh-spill'
+import * as SpillPolicy from '@deepseek-ai/dsh-spill-policy'
+import { formatSpillNotice } from '@deepseek-ai/dsh-spill-policy/notice'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
+import { describe, expect, it } from 'vitest'
+import { isSpilledShellCall, terminalCardModel } from '../src/client/tool/models/terminal-card-model.ts'
+
+const spillReference = {
+  locator: SpillLocator('/spill/shell.txt'),
+  retrievalHint: 'Read the saved text.',
+}
+
+class MemorySpillStore extends SpillStore {
+  readonly saves: { input: SaveTextSpill; bytes: Buffer }[] = []
+
+  async saveText(input: SaveTextSpill): Promise<SpillRef> {
+    const bytes = Buffer.from(input.content, 'utf8')
+    this.saves.push({ input, bytes })
+    return {
+      ...spillReference,
+      bytes: bytes.length,
+    }
+  }
+}
+
+const shellArgs = { command: 'fixture-output', description: 'Return shell output fixture' }
+
+async function executeShell(text: string, nested: boolean, name = 'bash', maxInlineBytes = 256) {
+  const ctx = new Context()
+  try {
+    await ctx.plugin(SystemPrompt)
+    await ctx.plugin(ToolRuntime, { mode: 'both' })
+    await ctx.plugin(MemorySpillStore)
+    await ctx.plugin(SpillPolicy, { maxInlineBytes })
+    if (nested) await ctx.plugin(WorkerThreadCodeRuntime, {})
+    ctx.effect(() => ctx.tools.register(defineContentToolFixture({
+      name,
+      description: 'Return deterministic shell text without spawning a process.',
+      parameters: {
+        command: { type: 'string', required: true },
+        description: { type: 'string', required: true },
+      },
+      async execute() { return [{ type: 'text', text }] },
+    })))
+    const session = Session.create(SessionId('spill-terminal'))
+    // The tool pipeline reads only the owner session; no agent loop runs here.
+    const agent = { session } as Agent
+    const callId = ToolCallId('shell-call')
+    const result = await ctx.tools.execute({
+      callId,
+      agent,
+      signal: new AbortController().signal,
+      name: nested ? 'run_code' : name,
+      arguments: nested ? {
+        code: `const blocks = await tools.${name}(${JSON.stringify(shellArgs)}); return blocks[0].text === ${JSON.stringify(text)};`,
+        description: 'Dispatch shell fixture through the tool pipeline',
+      } : shellArgs,
+    })
+    expect(result.isError).toBe(false)
+    if (result.isError) throw new Error('shell fixture execution failed')
+
+    let block: ToolResultNode
+    if (nested) {
+      expect(result.value).toMatchObject({ result: true })
+      const dispatches = session.snapshotEvents().filter(event => event.type === 'tool/code-dispatch')
+      expect(dispatches).toHaveLength(1)
+      const event = dispatches[0]!
+      block = {
+        kind: 'tool-result', seq: event.seq, time: event.time, callTime: null,
+        callId: event.data.subCallId, parentCallId: event.data.parentCallId,
+        call: { name: event.data.name, argsRaw: JSON.stringify(event.data.arguments) },
+        content: event.data.content, isError: event.data.isError, subCalls: [],
+      }
+    } else {
+      expect(result.value).toEqual([{ type: 'text', text }])
+      block = {
+        kind: 'tool-result', seq: 1, time: 1, callTime: null, callId,
+        call: { name, argsRaw: JSON.stringify(shellArgs) },
+        content: result.content, isError: result.isError, subCalls: [],
+      }
+    }
+    expect(block.isError).toBe(false)
+    const saves = (ctx.spillStore as MemorySpillStore).saves
+    return { block, saves }
+  } finally {
+    await ctx.fiber.dispose()
+  }
+}
+
+describe.each([
+  { location: 'root', nested: false },
+  { location: 'nested', nested: true },
+])('$location spill-policy shell terminal fallback', ({ nested }) => {
+  describe.each(['bash', 'pwsh'])('%s', (name) => {
+    it.each([
+      { outcome: 'nonzero exit', marker: '\n[exit code: 7]' },
+      { outcome: 'terminating signal', marker: '\n[killed by signal: SIGTERM]' },
+      { outcome: 'markerless output', marker: '' },
+    ])('keeps a spilled $outcome result generic', async ({ marker }) => {
+      const original = 'HEAD 雪\n'.repeat(200) + marker
+      const { block, saves } = await executeShell(original, nested, name)
+      expect(saves).toHaveLength(1)
+      expect(saves[0]!.input).toMatchObject({
+        owner: { sessionId: 'spill-terminal' },
+        source: { toolName: name, callId: block.callId, label: nested ? 'dispatch' : 'result' },
+        content: original,
+      })
+      expect(saves[0]!.bytes).toEqual(Buffer.from(original, 'utf8'))
+      expect(block.content).not.toEqual([{ type: 'text', text: original }])
+      expect(block.content).toHaveLength(1)
+      const preview = block.content[0]!
+      if (preview.type !== 'text') throw new Error('expected a plain-text spill preview')
+      expect(Buffer.byteLength(preview.text, 'utf8')).toBeLessThanOrEqual(256)
+      expect(preview.text).toContain('HEAD')
+      if (marker !== '') expect(preview.text).toContain(marker)
+      expect(terminalCardModel(block)).toBeNull()
+      expect(isSpilledShellCall(block)).toBe(true)
+    })
+
+    it('keeps a notice-only result generic when no preview fits', async () => {
+      const original = '雪'.repeat(1_000) + '\n[exit code: 9]'
+      const notice = formatSpillNotice({ kind: 'exact', count: Buffer.byteLength(original, 'utf8') }, spillReference)
+      const { block, saves } = await executeShell(original, nested, name, Buffer.byteLength(notice, 'utf8'))
+      expect(block.content).toEqual([{ type: 'text', text: notice }])
+      expect(saves).toHaveLength(1)
+      expect(saves[0]!.bytes).toEqual(Buffer.from(original, 'utf8'))
+      expect(terminalCardModel(block)).toBeNull()
+      expect(isSpilledShellCall(block)).toBe(true)
+    })
+
+    it('retains a terminal card when the result fits without spilling', async () => {
+      const original = 'short output\n[exit code: 7]'
+      const { block, saves } = await executeShell(original, nested, name)
+      expect(saves).toHaveLength(0)
+      expect(block.content).toEqual([{ type: 'text', text: original }])
+      expect(terminalCardModel(block)?.card).toMatchObject({ output: 'short output', exitCode: 7 })
+      expect(isSpilledShellCall(block)).toBe(false)
+    })
+  })
+})

+ 3 - 0
packages/client/ui-tool/tsconfig.json

@@ -8,6 +8,9 @@
     "src"
   ],
   "references": [
+    {
+      "path": "../../spill/spill-policy"
+    },
     {
       "path": "../../attachment/attachment"
     },

+ 2 - 2
packages/spill/spill-policy/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/spill/spill-policy/README.md
-README.md: 073efeaafdd2e5c94c1f2b18871c26a6eedfdaa7
-README.zh.md: b3a35b2d35bcec6646f92e508791659fe7b773cd
+README.md: 1ccf250bacd0b896e6f140393fd3447de3af1ab0
+README.zh.md: e6c537a1c9bd0c24a09063898638a12bfe84466d

+ 7 - 0
packages/spill/spill-policy/README.md

@@ -86,11 +86,17 @@ The policy is deliberately narrow: it only decides **when** to spill and compose
 
 A `tools/post-execute` waterfall listener (registered with `prepend`, delegating via `next()`) bounds the model-facing result; a `tools/ptc-dispatch-log` listener bounds the durable log copy of each `run_code` sub-call. Both share one replacement helper so the two projections are byte-identical. The post-execute arm skips `read` to avoid a read → spill → read loop; the dispatch-log arm bounds `read` sub-calls because a log copy is not model context.
 
+<a id="shared-notice-ownership"></a>
+### Shared notice ownership
+
+The browser-safe `@deepseek-ai/dsh-spill-policy/notice` entry owns both `formatSpillNotice(omitted, ref)`, used by the producer, and `hasSpillNotice(text)`, used by presentation consumers. Formatting and recognition share the notice delimiters; omission validation uses the existing `describeOmitted` formatter rather than a second copy of its prose. Recognition accepts a complete final notice after a preview or by itself and preserves the persisted notice spelling. It reads recorded text without rewriting it.
+
 ### Source map
 
 | File | Role |
 |---|---|
 | [`src/index.ts`](src/index.ts) | Plugin entry: `Config` validation, the two waterfall listeners, the shared replacement helper |
+| [`src/notice.ts`](src/notice.ts) | Browser-safe notice formatting and recognition, published as `./notice` |
 | [`src/types.ts`](src/types.ts) | `SpillPolicyExec`: the minimal structural view of a tool execution the policy reads for the owning session id |
 | — | No runtime invariant companion is published; this package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. |
 
@@ -138,6 +144,7 @@ Append-only; newly visible content follows the reusable request prefix and does
 
 These limits define when the policy cannot help. They are current package constraints.
 
+- **Text recognition cannot authenticate output** — a tool can print the same notice text; `hasSpillNotice` identifies a text convention, not proof that the policy saved a result.
 - **Only final plain-text results are spillable** — mixed-content results, blocked feedback, and `read` pass through; provider truncation or tool-owned retention that happened earlier cannot be recovered here.
 - **A notice that cannot fit disables replacement for that call** — a tiny cap or long locator leaves the oversized original inline after the backend has already saved an unreferenced spill.
 

+ 7 - 0
packages/spill/spill-policy/README.zh.md

@@ -86,11 +86,17 @@ kind: "package-reference"
 
 `tools/post-execute` waterfall(瀑布式事件)监听器(以 `prepend` 注册、通过 `next()` 委托)约束面向模型的结果;`tools/ptc-dispatch-log` 监听器约束每个 `run_code` 子调用的持久日志副本。两者共享同一个替换辅助函数,因此两个投影字节一致。post-execute 分支跳过 `read` 以避免 read → spill → read 循环;dispatch-log 分支约束 `read` 子调用,因为日志副本不是模型上下文。
 
+<a id="shared-notice-ownership"></a>
+### 共享通知的所有权
+
+浏览器安全入口 `@deepseek-ai/dsh-spill-policy/notice` 同时负责生产方使用的 `formatSpillNotice(omitted, ref)` 和展示消费方使用的 `hasSpillNotice(text)`。格式化与识别共用通知分隔符;省略信息通过现有的 `describeOmitted` 格式化函数校验,而非复制一套文案。识别支持预览之后或单独出现的完整末尾通知,并保留持久化通知的原有拼写。它只读取已记录的文本,不改写文本。
+
 ### 源码地图
 
 | 文件 | 职责 |
 |---|---|
 | [`src/index.ts`](src/index.ts) | 插件入口:`Config` 校验、两个 waterfall 监听器、共享替换辅助函数 |
+| [`src/notice.ts`](src/notice.ts) | 浏览器安全的通知格式化与识别,以 `./notice` 发布 |
 | [`src/types.ts`](src/types.ts) | `SpillPolicyExec`:策略读取所属会话 id 所需的最小结构化工具执行视图 |
 | — | 不发布运行时不变式伴生入口;约定在 seam 处强制执行。 |
 
@@ -138,6 +144,7 @@ kind: "package-reference"
 
 这些限制说明策略在哪些情况下无法提供帮助。它们是当前的包约束。
 
+- **文本识别无法认证输出来源**——工具也能打印相同的通知文本;`hasSpillNotice` 识别的是文本约定,不能证明策略保存过结果。
 - **只能对最终纯文本结果执行 spill**——混合内容结果、阻止反馈与 `read` 会原样通过;此前已经发生的提供方截断或工具自有保留无法在此恢复。
 - **通知无法容纳时会禁用该次调用的替换**——上限极小或定位信息很长时,后端已经保存了无引用的 spill,但过大的原始结果仍留在内联位置。
 

+ 5 - 0
packages/spill/spill-policy/package.json

@@ -18,11 +18,16 @@
       "types": "./lib/types/index.d.ts",
       "default": "./lib/index.js"
     },
+    "./notice": {
+      "types": "./lib/types/notice.d.ts",
+      "default": "./lib/notice.js"
+    },
     "./src/*": "./src/*",
     "./package.json": "./package.json"
   },
   "files": [
     "lib/index.js",
+    "lib/notice.js",
     "lib/types/**/*.d.ts"
   ],
   "license": "MIT",

+ 4 - 9
packages/spill/spill-policy/src/index.ts

@@ -46,13 +46,14 @@
 import type { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 import type { ContentBlock } from '@deepseek-ai/dsh-llm'
-import { TextRetainer, describeOmitted } from '@deepseek-ai/dsh-output-retention'
+import { TextRetainer } from '@deepseek-ai/dsh-output-retention'
 import type { Omitted } from '@deepseek-ai/dsh-output-retention'
 import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
 import type { SessionId } from '@deepseek-ai/dsh-session'
 import type { ToolCallId } from '@deepseek-ai/dsh-llm'
 import type { PostToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
 import type { SpillPolicyExec } from './types.ts'
+import { formatSpillNotice } from './notice.ts'
 
 export type { SpillPolicyExec } from './types.ts'
 
@@ -101,12 +102,6 @@ function preview(text: string, budget: number): { text: string; omitted: Omitted
   return { text: kept.text, omitted: kept.omittedBytes }
 }
 
-/** The spill-notice line for a given omission + saved reference (no preview, no leading blank line). */
-function spillNotice(omitted: Omitted, ref: SpillRef): string {
-  const omission = describeOmitted(omitted, 'bytes')
-  return `(${omission} Full formatted result stored at: ${ref.locator}. ${ref.retrievalHint})`
-}
-
 export function apply(ctx: Context, config: Config): void {
   const maxInlineBytes = config.maxInlineBytes
   // Omitted ⇒ no automatic spill policy: register nothing at all.
@@ -168,10 +163,10 @@ export function apply(ctx: Context, config: Config): void {
     // count (the full byte total): its digit count bounds the real count's, so
     // the reserved size is a safe upper bound and the final notice is never
     // longer than what we reserved. `\n\n` is the 2-byte join.
-    const reserve = Buffer.byteLength(spillNotice({ kind: 'exact', count: totalBytes }, ref), 'utf8') + 2
+    const reserve = Buffer.byteLength(formatSpillNotice({ kind: 'exact', count: totalBytes }, ref), 'utf8') + 2
     const previewBudget = Math.max(0, cap - reserve)
     const { text: previewText, omitted } = preview(text, previewBudget)
-    const notice = spillNotice(omitted, ref)
+    const notice = formatSpillNotice(omitted, ref)
     const replacedText = previewText.length > 0 ? `${previewText}\n\n${notice}` : notice
     // Invariant: the policy NEVER emits a replacement larger than the cap. When
     // the notice alone exceeds maxInlineBytes (a tiny cap or a long spill root),

+ 52 - 0
packages/spill/spill-policy/src/notice.ts

@@ -0,0 +1,52 @@
+/** Browser-safe formatting and recognition of persisted spill-policy notices. */
+import { describeOmitted, type Omitted } from '@deepseek-ai/dsh-output-retention'
+import type { SpillRef } from '@deepseek-ai/dsh-spill'
+
+const OPEN = '('
+const CLOSE = ')'
+const LOCATION = ' Full formatted result stored at: '
+const GUIDANCE_SEPARATOR = '. '
+const SEPARATOR = '\n\n'
+const EXACT_OMISSION = describeOmitted({ kind: 'exact', count: 0 }, 'bytes')
+const COUNT_OFFSET = EXACT_OMISSION.indexOf('0')
+const COUNT_SUFFIX = EXACT_OMISSION.slice(COUNT_OFFSET + 1)
+
+/**
+ * Format the notice appended to a retained preview, preserving its persisted spelling.
+ * @param omitted - bytes omitted by the retention policy.
+ * @param ref - saved text locator and retrieval guidance.
+ * @returns the complete notice without a leading preview separator.
+ */
+export function formatSpillNotice(omitted: Omitted, ref: Pick<SpillRef, 'locator' | 'retrievalHint'>): string {
+  return `${OPEN}${describeOmitted(omitted, 'bytes')}${LOCATION}${ref.locator}${GUIDANCE_SEPARATOR}${ref.retrievalHint}${CLOSE}`
+}
+
+function isOmission(text: string): boolean {
+  if (text === describeOmitted({ kind: 'none' }, 'bytes')
+    || text === describeOmitted({ kind: 'unknown' }, 'bytes')) return true
+  const count = Number(text.slice(COUNT_OFFSET, text.length - COUNT_SUFFIX.length))
+  return Number.isSafeInteger(count) && count >= 0
+    && text === describeOmitted({ kind: 'exact', count }, 'bytes')
+}
+
+/**
+ * Recognize a final spill-policy notice in persisted text, including notice-only output.
+ * This identifies the text convention, not authenticated provenance of tool output.
+ * @param text - complete recorded text result.
+ * @returns whether a complete notice occupies the end of the result.
+ */
+export function hasSpillNotice(text: string): boolean {
+  if (!text.endsWith(CLOSE)) return false
+  let start = 0
+  while (true) {
+    const next = text.indexOf(`${SEPARATOR}${OPEN}`, start)
+    const candidate = text.slice(start, next < 0 ? -CLOSE.length : next)
+    const location = candidate.indexOf(LOCATION, OPEN.length)
+    if (candidate.startsWith(OPEN) && location >= 0
+      && isOmission(candidate.slice(OPEN.length, location))) {
+      return text.indexOf(GUIDANCE_SEPARATOR, start + location + LOCATION.length) >= 0
+    }
+    if (next < 0) return false
+    start = next + SEPARATOR.length
+  }
+}

+ 47 - 0
packages/spill/spill-policy/tests/notice.spec.ts

@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest'
+import { SpillLocator } from '@deepseek-ai/dsh-spill'
+import { formatSpillNotice, hasSpillNotice } from '../src/notice.ts'
+
+const ref = { locator: SpillLocator('/spill/output.txt'), retrievalHint: 'Read the file.' }
+
+describe('persisted spill notice', () => {
+  it('preserves the historical spelling independently of the formatter', () => {
+    const historical = '(Omitted 50000 bytes. Full formatted result stored at: /spill/output.txt. Read the file.)'
+    expect(formatSpillNotice({ kind: 'exact', count: 50000 }, ref)).toBe(historical)
+    expect(hasSpillNotice(historical)).toBe(true)
+    expect(hasSpillNotice(`failed\n[exit code: 7]\n\n${historical}`)).toBe(true)
+  })
+
+  it('recognizes every omission form the producer can format', () => {
+    for (const omitted of [{ kind: 'none' }, { kind: 'unknown' }, { kind: 'exact', count: 0 }] as const) {
+      const notice = formatSpillNotice(omitted, ref)
+      expect(hasSpillNotice(notice)).toBe(true)
+      expect(hasSpillNotice(`preview\n\n${notice}`)).toBe(true)
+    }
+  })
+
+  it('keeps locator and retrieval text opaque, including parentheses and newlines', () => {
+    const notice = formatSpillNotice({ kind: 'exact', count: 42 }, {
+      locator: SpillLocator('/spill/报告 (1).txt'), retrievalHint: 'Read it.\n\n(additional guidance)',
+    })
+    expect(hasSpillNotice(notice)).toBe(true)
+    expect(hasSpillNotice(`preview\n\n${notice}`)).toBe(true)
+  })
+
+  it('scans repeated non-notice prefixes without losing the final notice', () => {
+    const prefix = '\n\n(ordinary output'.repeat(20000)
+    expect(hasSpillNotice(prefix + ')')).toBe(false)
+    const notice = formatSpillNotice({ kind: 'exact', count: 42 }, ref)
+    expect(hasSpillNotice(prefix + '\n\n' + notice)).toBe(true)
+  })
+
+  it('rejects ordinary, partial, non-final, and malformed notice text', () => {
+    const notice = formatSpillNotice({ kind: 'exact', count: 42 }, ref)
+    for (const text of [
+      '', 'ordinary output)', '(ordinary output)', '\n\n(ordinary output)', 'prefix' + notice,
+      notice.slice(0, -1), notice + '\nother output',
+      notice.replace('42', '-1'), notice.replace('42', '1.5'), notice.replace('42', '0042'),
+      notice.replace('Omitted', 'Ignored'), notice.replace('. Read the file.', ''),
+    ]) expect(hasSpillNotice(text)).toBe(false)
+  })
+})

+ 8 - 0
packages/spill/spill-policy/tsdown.config.ts

@@ -0,0 +1,8 @@
+import { defineConfig } from 'tsdown'
+
+export default defineConfig([
+  { entry: ['lib/types/index.js'], outDir: 'lib', platform: 'node', format: 'esm', clean: false, dts: false },
+  {
+    entry: ['lib/types/notice.js'], outDir: 'lib', platform: 'neutral', format: 'esm', clean: false, dts: false,
+  },
+])

+ 24 - 0
pnpm-lock.yaml

@@ -3519,6 +3519,9 @@ importers:
       '@deepseek-ai/cordis':
         specifier: workspace:^
         version: link:../../../vendor/cordis
+      '@deepseek-ai/dsh-agent':
+        specifier: workspace:^
+        version: link:../../core/agent
       '@deepseek-ai/dsh-api-remotes':
         specifier: workspace:^
         version: link:../../api/remotes
@@ -3555,6 +3558,27 @@ importers:
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots
+      '@deepseek-ai/dsh-code-runtime-worker-thread':
+        specifier: workspace:^
+        version: link:../../code-runtime/code-runtime-worker-thread
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      '@deepseek-ai/dsh-session':
+        specifier: workspace:^
+        version: link:../../core/session
+      '@deepseek-ai/dsh-spill':
+        specifier: workspace:^
+        version: link:../../spill/spill
+      '@deepseek-ai/dsh-spill-policy':
+        specifier: workspace:^
+        version: link:../../spill/spill-policy
+      '@deepseek-ai/dsh-system-prompt':
+        specifier: workspace:^
+        version: link:../../core/system-prompt
+      '@deepseek-ai/dsh-tools':
+        specifier: workspace:^
+        version: link:../../core/tools
       '@deepseek-ai/dsh-util-workspace-path':
         specifier: workspace:^
         version: link:../../util/workspace-path

+ 7 - 0
scripts/client-bundle-purity.spec.ts

@@ -101,6 +101,13 @@ describe('client bundle purity gate', () => {
     expect(() => resolveId('@deepseek-ai/dsh-token-meter/client/internal')).toThrow(/purity/)
   })
 
+  it('admits only the pure spill notice entry, not its Host policy', () => {
+    expect(resolveId('@deepseek-ai/dsh-spill-policy/notice')).toBeNull()
+    expect(resolveId('@deepseek-ai/dsh-output-retention')).toBeNull()
+    expect(() => resolveId('@deepseek-ai/dsh-spill-policy')).toThrow(/purity/)
+    expect(() => resolveId('@deepseek-ai/dsh-spill-policy/notice/internal')).toThrow(/purity/)
+  })
+
   it('lets exact generated Remote contributions inline without admitting their package implementation', () => {
     expect(resolveId('@deepseek-ai/dsh-goal/remote')).toBeNull()
     expect(() => resolveId('@deepseek-ai/dsh-goal')).toThrow(/purity/)

+ 1 - 0
tsconfig.base.json

@@ -40,6 +40,7 @@
       "@deepseek-ai/node-addon-landlock-run": ["./native/landlock-run/packages/entry/src/index.ts"],
       "@deepseek-ai/dsh-invariants": ["./packages/runtime-diagnostics/invariants/src/index.ts"],
       "@deepseek-ai/dsh-tool-call-timeout-policy": ["./packages/guard/timeout-policy/src"],
+      "@deepseek-ai/dsh-spill-policy/notice": ["./packages/spill/spill-policy/src/notice.ts"],
       "@deepseek-ai/dsh-typert-registry": ["./packages/typert/registry/src/index.ts"],
       "@deepseek-ai/dsh-typert-registry/client": ["./packages/typert/registry/src/client/index.ts"],
       "@deepseek-ai/dsh-api-gateway": ["./packages/api/gateway/src/index.ts"],

+ 2 - 0
tsconfig.client.json

@@ -38,6 +38,8 @@
     "packages/client/*/tests/**/*.host.spec.ts"
   ],
   "references": [
+    // Producer-to-UI spill tests execute the real nested dispatch worker.
+    { "path": "./packages/code-runtime/code-runtime-worker-thread" },
     // Shared leaf: web e2e boots the real host webserver (fixture + real-host
     // smoke policy). webserver has zero workspace deps and no cordis merge,
     // so it cannot drag host-side Context augmentation into this program.