From 60fcb494a763dec5ad8e431f922e016799cb529f Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:19:25 -0700 Subject: [PATCH 01/39] docs(i18n): restore prompt-v4 as the pipeline baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 jingtingxiang 拍板将 prompt 回到 v4 基线:模板正文恢复内嵌的 格式/语气/句式/词汇/标点全量约束与 11 组正误例(量词规则按术语表 现行裁定 package→包 写作「由三个包构成的 seam」),不再注入 translation-rules.md——该文件约束人和 agent,不进模板;占位符收敛 为 source_lang/target_lang/terminology 三个,切换行由模型按文档 自身拼写。渲染器、解析器、conformance 门禁与单测同步回 v4 契约: 三段裸 XML(translation/review/final 顺序唯一),容忍整体 ```xml 围栏回显;saxes 依赖随 CDATA 协议一并移除。 --- docs/i18n/translation-prompt.md | 168 +++++++++++++++++++-------- package.json | 1 - pnpm-lock.yaml | 22 ++-- scripts/translation-prompt.spec.ts | 87 +++++--------- scripts/translation-prompt.ts | 144 +++++++---------------- scripts/verify-translation-prompt.ts | 28 ++--- 6 files changed, 213 insertions(+), 237 deletions(-) diff --git a/docs/i18n/translation-prompt.md b/docs/i18n/translation-prompt.md index 8bc15d6e8e..d59170b00a 100644 --- a/docs/i18n/translation-prompt.md +++ b/docs/i18n/translation-prompt.md @@ -1,6 +1,6 @@ # Translation prompt (pipeline asset) -本文件是自动翻译流水线使用的 prompt 模板;从 `# Translation Prompt` 开始的正文会逐字进入模型请求,因此本文件不参与双语配对(见 [README.md](README.md) 排除清单)。渲染时会把 [translation-rules.md](translation-rules.md) 全文填入 `{{translation_rules}}`,把 [terminology.md](terminology.md) 整表填入 `{{terminology}}`,以免模板另存一份规则而日后失去同步。[style-samples.md](style-samples.md) 定义文体,模板中的 Examples 只用于说明典型问题;术语表、忠实性和结构规则优先于样例,样例只在这些硬性约束内决定文体。修改本文件会改变翻译行为,需正常经过 PR 评审。 +本文件是自动翻译流水线的 prompt 模板;从 `# Translation Prompt` 开始的正文会逐字进入模型请求,因此本文件不参与双语配对(见 [README.md](README.md) 排除清单)。模板正文与内嵌 few-shot 正误例由 jingtingxiang 基于对存量译文的质量评审撰写,是流水线行为的拍板基线。渲染时把 [terminology.md](terminology.md) 整表填入 `{{terminology}}`;除此之外不注入任何其他仓库文件(translation-rules.md 约束人和 agent 的翻译工作,不注入本模板)。[style-samples.md](style-samples.md) 定义文体,模板中的 Examples 只用于说明典型问题,两者冲突时以文体样例为准。修改本文件会改变翻译行为,需正常经过 PR 评审。 ## 占位符契约 @@ -10,18 +10,13 @@ |---|---|---| | `{{source_lang}}` | 源语言名(`English` / `Chinese`) | 由改动侧文件推断:`.zh.md` 被改则为 `Chinese` | | `{{target_lang}}` | 目标语言名(`Chinese` / `English`) | 与 `{{source_lang}}` 相对 | -| `{{translation_rules}}` | [translation-rules.md](translation-rules.md) 全文(Markdown 原文) | 渲染时读取仓库当前版本,不缓存 | | `{{terminology}}` | [terminology.md](terminology.md) 的完整表格(Markdown 原文) | 渲染时读取仓库当前版本,不缓存 | -| `{{source_filename}}` | 源文档的 basename(如 `foo.md` 或 `foo.zh.md`) | 由流水线从待译文件路径取得 | -| `{{source_filename_zh}}` | 中文侧 basename(如 `foo.zh.md`) | 英文源追加 `.zh`;中文源使用自身 basename | -例如,英译中时若源文件是 `foo.md`,`{{source_filename}}` 填 `foo.md`,`{{source_filename_zh}}` 填 `foo.zh.md`;中译英时若源文件是 `foo.zh.md`,两个占位符都填 `foo.zh.md`。 - -流水线只识别上表中的占位符,并且一次翻译整篇文档。它不支持 `{{to}}`、`{{title_prompt}}`、`{{summary_prompt}}`、`{{terms_prompt}}`、`{{imt_style_guide}}` 或 `%%` 分段协议。输出必须是一个以 `` 为根元素的 XML 文档;三个子元素中的 Markdown 内容都放在 CDATA 中。内容出现 `]]>` 时写成 `]]]]>`,XML 解析后仍会还原为原文。 +流水线只识别上表中的占位符,并且一次翻译整篇文档。它不支持 `{{to}}`、`{{title_prompt}}`、`{{summary_prompt}}`、`{{terms_prompt}}`、`{{imt_style_guide}}`、`{{translation_rules}}` 或 `%%` 分段协议;输出采用模板正文规定的三段 XML,流水线解析取 `` 段。 ## Few-shot 金标 -流水线使用**整篇文档**的中英对照作为 few-shot,不是模板内嵌的句子级正误例。以下 5 组配对文档均经过人工评审,并以仓库当前版本为准,随仓库一同更新: +流水线使用**整篇文档**的中英对照作为 few-shot,不是模板内嵌的句子级正误例。以下 5 组配对文档均经过人工评审,以仓库当前版本为准、随仓库更新: - `README.md` ↔ `README.zh.md` - `docs/development.md` ↔ `docs/development.zh.md` @@ -29,58 +24,131 @@ - `docs/i18n/translation-rules.md` ↔ `docs/i18n/translation-rules.zh.md` - `docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md` ↔ 对应 `.zh.md` -注入时按当前翻译方向选择每组的源侧与目标侧:user 消息包含源文档全文,assistant 消息采用模板正文规定的 XML 协议;`translation` 与 `final` 都放入目标文档全文,`review` 填 `- [None] No corrections.`。CDATA 遵循上文的 `]]>` 拆分规则。上下文不足时,按上列顺序从后往前删减示例组数。这 5 组也是评审校准锚点;改动任何一组都会改变流水线行为。 +注入方式:在系统消息(本模板)之后、待译文档之前,每组作为一轮示例对话——user 消息为源文档全文,assistant 消息为定稿译文全文(裸文本,不带三段 XML 包装;只有真实请求要求三段输出)。上下文不足时按上列顺序从后往前删减组数。这 5 组也是评审校准锚点(见 [style-samples.md](style-samples.md)),改动任何一组即改变流水线行为。 ## 模板正文 ````text # Translation Prompt -You are a senior technical translator specializing in LLM and agent development documentation. Translate the complete source document from {{source_lang}} to {{target_lang}} as natural, professional technical prose. +You are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the given source document from {{source_lang}} to {{target_lang}}, producing natural, professional technical prose. -## Binding Translation Rules +## Quality Requirements -The canonical repository rules below are injected verbatim. Apply every direction-appropriate requirement. In those rules, the authored document is the source for this request and the generated document is its counterpart. +### Structure and Format Preservation +- Output a complete translated document that maintains exactly the same structure as the source: heading hierarchy, list shape, table columns, link targets, and code blocks. +- Fenced code blocks must be byte-identical to the source, including ALL comments inside them. Do NOT translate comments inside code blocks. This is a hard rule with no exceptions. +- Inline code spans (commands, flags, paths, API names, version numbers) must be kept verbatim. Never translate or reformat them. +- Every relative link must point to the same target as in the source. Link text is translated; link targets are not. +- Language switcher line: when translating into Chinese, write `[English](source-filename.md) | 中文`. When translating into English, write `English | [中文](source-filename.zh.md)`. Do NOT copy the switcher line from the source file unchanged — you must flip the link direction. +- After a closing bold marker `**`, always insert a space before the next character. -{{translation_rules}} +### Tone and Style +- The translation must read as if originally written in the target language by a native speaker. If an expression sounds like a word-for-word rendering from the source language, rephrase it. +- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions. +- Use polite imperative forms where the text instructs the reader to do something. +- Keep the author's register: concise stays concise, detailed stays detailed. -## Request-Specific Structure +### Sentence Structure +- Break long sentences with commas or semicolons. Avoid run-on sentences. +- Prefer active voice. Convert passive constructions to active if it reads more naturally. +- Translate meaning, not words. Restructure sentences where the target language grammar requires it. +- Do not invent words or expressions that do not exist in natural technical writing of the target language. -- The source basename is `{{source_filename}}`. When translating into Chinese, write `[English]({{source_filename}}) | 中文` immediately after the H1. When translating into English, write `English | [中文]({{source_filename_zh}})` immediately after the H1. -- Emit the switcher for a new pair and flip an existing switcher; never copy it unchanged. +### Word Choice +- Prefer precise, formal vocabulary over casual or colloquial alternatives. +- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language. +- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience. +- Do not use the same word to translate two different source-language terms that carry distinct meanings. +- Avoid repeating the same verb in close proximity; vary word choice for readability. -## Binding Terminology +#### When translating into Chinese +- When a number modifies a noun, always include a Chinese classifier or measure word (量词). For example: "three-package seam" → "由三个包构成的 seam", not "三包 seam". -Apply the current table below exactly as required by the injected translation rules. +### Punctuation + +#### When translating into Chinese +- Use full-width Chinese punctuation in prose: `,。:;?!()「」`. +- Strongly prefer replacing all em-dashes (——) with colons, periods, commas, or parentheses. Keep an em-dash only if no other punctuation works at all. +- Use enumeration commas (、) between parallel items, not regular commas. +- List item endings: use semicolons or no punctuation. Do not end list items with commas. +- Put one half-width space between Chinese text and Latin words/numbers. +- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), render the corresponding Chinese term in italics: *必须*、*禁止*、*应当*、*可以*. + +#### When translating into English +(To be added.) + +## Terminology + +A terminology table is provided below. Follow it strictly: +- Render every listed term exactly as specified. +- First occurrence: write as shown in the "首次出现" column (with parenthetical gloss). Subsequent occurrences: write only the part before the parentheses. +- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later. +- NEVER use translations listed in the "不要译作" column. +- For technical terms not in the table: keep them in the source language. Do not invent a translation. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression. {{terminology}} ## Output Format -Return exactly one well-formed XML document with this root and these three child elements. Do not wrap it in a Markdown code fence. Put all Markdown and review text inside CDATA. If any content contains the CDATA terminator, split it as `]]]]>` so XML parsing reconstructs the original `]]>` sequence. +Produce your output in three XML sections: ```xml - - - - - + +(Complete translation of the source document) + + + +(Self-review notes, one correction per line with category tag, e.g.) +- [Tone] "旁挂记录" → "伴随记录"(生造词) +- [Sentence] 第 3 段补充逗号断句 +- [Punctuation] 两处破折号替换为冒号 +- 无修正 + + + +(Final translation after corrections) + ``` ## Self-Review Instructions -After writing ``, re-read it in the target language without looking at the source. Then apply the injected translation rules as a clause-by-clause comparison against the source and record actual corrections in English inside ``. Apply every recorded correction in ``. If no correction is needed, write only `- [None] No corrections.` in `` and copy `` unchanged into ``. +After writing ``, re-read it in the target language only, without looking at the source. Check by category: + +**Structure** +- Is the heading hierarchy, list shape, and code block content identical to the source? +- Are ALL comments inside code blocks left untranslated (byte-identical to source)? +- Is the language switcher line correctly flipped (not copied from source)? +- Are link targets preserved and bold markers followed by a space? + +**Tone & Style** +- Does every sentence read as if originally written by a native speaker? +- Is there any colloquial, casual, or overly informal phrasing? + +**Sentence Structure** +- Are there run-on sentences that need breaking? +- Are there stiff passive constructions that should be converted to active voice? + +**Word Choice** +- Are there overly literal translations that sound unnatural? +- Is the same target-language word used to translate two distinct source concepts? +- Is any slang or internal jargon present? + +**Terminology** +- Are first-occurrence glosses correctly applied (not missing, not repeated)? +- Are any "不要译作" forbidden translations present? +- Are unlisted terms correctly kept in the source language? + +**Punctuation** (when target is Chinese) +- Are there em-dashes that should be replaced with colons, periods, or commas? +- Are list items ending with commas instead of semicolons? +- Are RFC 2119 keywords rendered in italics? + +Record corrections in `` with category tags. Then output the corrected version in ``. If no corrections are needed, write "无修正" in `` and copy the translation unchanged into ``. ## Examples -Follow the Good versions; these sentence-level examples illustrate error categories, not the assistant-message wire format. +Below are representative examples of common problems and their corrections. Follow the "Good" versions. ### Colloquial verb → Professional verb - Source: `The repo pins pnpm@11.7.0 in package.json` @@ -102,40 +170,40 @@ Follow the Good versions; these sentence-level examples illustrate error categor - Bad: `旁挂记录两侧 blob hash,使一致性可检查` - Good: `伴随记录保存两侧 blob hash,使一致性可检查` +### Em-dash → Colon/period +- Source: `FIXME — an issue that should block a new release. A release should not ship with an open FIXME unless reviewers explicitly agree the change can be merged anyway.` +- Bad: `FIXME——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 FIXME 出门。` +- Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。` + ### Overly literal → Meaningful rendering - Source: `awkward phrasing is easier to hear without the source anchoring you` - Bad: `没有源文锚着,别扭的表述更容易被听出来` - Good: `不对照原文时,更容易察觉别扭的表达` -### Terminology — keep the binding English form +### Terminology — do not translate what should be kept in English - Source: `typed service seams, and explicit extension points` - Bad: `类型化的服务 seam(扩展点)与显式扩展点` - Good: `类型化的服务 seam 与显式扩展点` -### Slang → Professional phrasing +### Slang/jargon → Professional phrasing - Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs` - Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs` - Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs` -### Chinese → English — idiomatic subject and predicate -- Source: `门禁绿并不代表译文内容正确。` -- Bad: `The gate green does not represent that the translation content is correct.` -- Good: `A green gate does not mean the translation is correct.` +### "For humans" — translate the intent, not the word +- Source: `For humans, start with the development guide` +- Bad: `对于人工读者,请先从开发指南开始`("人工读者"生硬) +- Good: `面向开发者:请先阅读开发指南`("开发者"自然,且中文里冒号在此处更自然) -### Code block comments — never translate +### Code block comments — NEVER translate - Source code block contains: `# REPL agent demo (needs DEEPSEEK_API_KEY)` - Bad: `# REPL agent 演示(需要 DEEPSEEK_API_KEY)` -- Good: `# REPL agent demo (needs DEEPSEEK_API_KEY)` (byte-identical) +- Good: `# REPL agent demo (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte) -### Language switcher — English to Chinese -- Source: `English | [中文](README.zh.md)` -- Bad: `English | [中文](README.zh.md)` -- Good: `[English](README.md) | 中文` - -### Language switcher — Chinese to English -- Source: `[English](README.md) | 中文` -- Bad: `[English](README.md) | 中文` -- Good: `English | [中文](README.zh.md)` +### Language switcher — flip direction +- Source file (English) has: `English | [中文](README.zh.md)` +- Bad (copying source unchanged): `English | [中文](README.zh.md)` +- Good (flipped for Chinese file): `[English](README.md) | 中文` --- diff --git a/package.json b/package.json index aaec1e62c4..51fd789a2b 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,6 @@ "mermaid": "11.16.0", "micromark-extension-gfm": "^3.0.0", "publint": "^0.3.21", - "saxes": "^6.0.0", "tsdown": "^0.22.2", "tsx": "^4.22.4", "typescript": "^6.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1a3a78030..0e71da2ea1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,9 +68,6 @@ importers: publint: specifier: ^0.3.21 version: 0.3.21 - saxes: - specifier: ^6.0.0 - version: 6.0.0 tsdown: specifier: ^0.22.2 version: 0.22.2(oxc-resolver@11.20.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3) @@ -819,7 +816,7 @@ importers: dependencies: '@earendil-works/pi-ai': specifier: ^0.79.1 - version: 0.79.3(ws@8.21.0)(zod@4.4.3) + version: 0.79.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) schemastery: specifier: ^3.18.0 version: 3.18.0 @@ -3059,6 +3056,10 @@ packages: cpu: [x64] os: [win32] + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -6137,11 +6138,11 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} - '@earendil-works/pi-ai@0.79.3(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-ai@0.79.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0 + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) '@mistralai/mistralai': 2.2.1 '@smithy/node-http-handler': 4.7.3 http-proxy-agent: 7.0.2 @@ -6299,12 +6300,14 @@ snapshots: '@exodus/bytes@1.15.1': {} - '@google/genai@1.52.0': + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))': dependencies: google-auth-library: 10.7.0 p-retry: 4.6.2 protobufjs: 7.6.4 ws: 8.21.0 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) transitivePeerDependencies: - bufferutil - supports-color @@ -6563,6 +6566,9 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.20.0': optional: true + '@pkgjs/parseargs@0.11.0': + optional: true + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -8034,6 +8040,8 @@ snapshots: jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 jiti@2.7.0: {} diff --git a/scripts/translation-prompt.spec.ts b/scripts/translation-prompt.spec.ts index 93754a79a1..db3f8b31a0 100644 --- a/scripts/translation-prompt.spec.ts +++ b/scripts/translation-prompt.spec.ts @@ -1,5 +1,7 @@ -/** Regression tests for the executable translation prompt contract. */ +/** Unit tests for the prompt-v4 renderer and three-section response parser. */ +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { parseTranslationResponse, @@ -7,70 +9,43 @@ import { renderTranslationResponse, } from './translation-prompt.ts' -const document = `# Wrapper - -## 模板正文 - -\`\`\`\`text -{{source_lang}} to {{target_lang}} -{{translation_rules}} -{{terminology}} -[English]({{source_filename}}) | [中文]({{source_filename_zh}}) -\`\`\`\` -` +const root = resolve(import.meta.dirname, '..') +const document = readFileSync(join(root, 'docs/i18n/translation-prompt.md'), 'utf8') +const terminology = '| English | 中文 |\n|---|---|\n| agent | agent |' describe('translation prompt rendering', () => { - it('renders every supported placeholder without recursively rewriting injected rules', () => { - const rendered = renderTranslationPrompt(document, { - sourceLanguage: 'English', - sourceFilename: 'guide.md', - translationRules: 'A literal {{source_lang}} in injected rules.', - terminology: '| English | 中文 |', - }) - expect(rendered).toContain('English to Chinese') - expect(rendered).toContain('A literal {{source_lang}} in injected rules.') - expect(rendered).toContain('[English](guide.md) | [中文](guide.zh.md)') + it('renders both directions with every placeholder resolved', () => { + const en = renderTranslationPrompt(document, { sourceLanguage: 'English', terminology }) + expect(en).toContain('from English to Chinese') + expect(en).toContain(terminology) + expect(en).not.toContain('{{') + const zh = renderTranslationPrompt(document, { sourceLanguage: 'Chinese', terminology }) + expect(zh).toContain('from Chinese to English') }) - it('rejects a filename whose suffix contradicts the source language', () => { - expect(() => renderTranslationPrompt(document, { - sourceLanguage: 'Chinese', - sourceFilename: 'guide.md', - translationRules: 'rules', - terminology: 'terms', - })).toThrow('does not match source language Chinese') - }) - - it('rejects malformed template placeholders before injecting rule contents', () => { - expect(() => renderTranslationPrompt(document.replace('{{source_lang}}', '{{source-lang}}'), { - sourceLanguage: 'English', - sourceFilename: 'guide.md', - translationRules: 'A literal {{source_lang}} in injected rules.', - terminology: '| English | 中文 |', - })).toThrow('template contains malformed placeholder syntax') + it('rejects a template with unknown or missing placeholders', () => { + const alien = document.replaceAll('{{terminology}}', '{{terms_prompt}}') + expect(() => renderTranslationPrompt(alien, { sourceLanguage: 'English', terminology })).toThrow(/unsupported placeholder/) + const missing = document.replaceAll('{{terminology}}', '') + expect(() => renderTranslationPrompt(missing, { sourceLanguage: 'English', terminology })).toThrow(/required placeholder/) }) }) -describe('translation response XML', () => { - it('round-trips Markdown and the CDATA terminator', () => { - const response = { - translation: '# Draft\n\nA ]]> marker.', - review: '- [Tone] Fixed.', - final: '# Final\n\nA ]]> marker.', - } +describe('translation response sections', () => { + it('round-trips Markdown bodies', () => { + const response = { translation: '# 标题\n\n正文 **加粗**。', review: '- [Tone] 修正一处。\n- 无修正', final: '# 标题\n\n定稿。' } expect(parseTranslationResponse(renderTranslationResponse(response))).toEqual(response) }) - it('rejects missing, reordered, nested, attributed, or non-CDATA children', () => { - expect(() => parseTranslationResponse('')).toThrow('translation, review, and final') - expect(() => parseTranslationResponse('')) - .toThrow('expected translation, got review') - expect(() => parseTranslationResponse(renderTranslationResponse({ translation: 'x', review: 'y', final: 'z' }) - .replace('', ''))) - .toThrow('nested element b is not allowed') - expect(() => parseTranslationResponse(renderTranslationResponse({ translation: 'x', review: 'y', final: 'z' }).replace('', ''))) - .toThrow('review must not have attributes') - expect(() => parseTranslationResponse(renderTranslationResponse({ translation: 'x', review: 'y', final: 'z' }).replace('', 'x'))) - .toThrow('all response field content must be inside CDATA') + it('tolerates a fenced xml wrapper around the whole response', () => { + const fenced = '```xml\n\nA\n\n\n\n- 无修正\n\n\n\nA\n\n```' + expect(parseTranslationResponse(fenced).final).toBe('A') + }) + + it('rejects missing, unterminated, or duplicated sections', () => { + expect(() => parseTranslationResponse('\nA\n')).toThrow(/missing /) + expect(() => parseTranslationResponse('\nA')).toThrow(/unterminated /) + const dup = '\nA\n\n\nR\n\n\nF\n\n\nG\n' + expect(() => parseTranslationResponse(dup)).toThrow(/duplicate /) }) }) diff --git a/scripts/translation-prompt.ts b/scripts/translation-prompt.ts index e30c962498..0556ff39fa 100644 --- a/scripts/translation-prompt.ts +++ b/scripts/translation-prompt.ts @@ -1,20 +1,16 @@ /** - * Executable renderer and strict response parser for the committed - * documentation-translation prompt contract. + * Executable renderer and response parser for the committed + * documentation-translation prompt contract (prompt-v4). + * + * The v4 contract: three placeholders (`source_lang`, `target_lang`, + * `terminology`), whole-document translation, and a three-section response + * (``, ``, `` in order, bare XML tags with raw + * Markdown bodies). The switcher filename is spelled out by the model from + * the document itself; the pipeline injects no other repository file. */ -import { basename } from 'node:path' -import { SaxesParser } from 'saxes' - /** Placeholder names supported by the committed translation prompt. */ -export const TRANSLATION_PROMPT_PLACEHOLDERS = [ - 'source_lang', - 'target_lang', - 'translation_rules', - 'terminology', - 'source_filename', - 'source_filename_zh', -] as const +export const TRANSLATION_PROMPT_PLACEHOLDERS = ['source_lang', 'target_lang', 'terminology'] as const type TranslationPromptPlaceholder = (typeof TRANSLATION_PROMPT_PLACEHOLDERS)[number] @@ -24,15 +20,11 @@ type TranslationLanguage = 'English' | 'Chinese' /** Inputs that vary for one rendered translation request. */ export interface TranslationPromptInput { sourceLanguage: TranslationLanguage - /** Source basename, including `.md` or `.zh.md`. */ - sourceFilename: string - /** Complete current `translation-rules.md` contents. */ - translationRules: string /** Complete current `terminology.md` contents. */ terminology: string } -/** Parsed contents of the three-element XML response. */ +/** Parsed contents of the three-section response. */ export interface TranslationResponse { translation: string review: string @@ -42,7 +34,7 @@ export interface TranslationResponse { const PLACEHOLDER = /{{([a-z_]+)}}/g const TEMPLATE_OPEN = '## 模板正文\n\n````text\n' const TEMPLATE_CLOSE = '\n````' -const RESPONSE_CHILDREN = ['translation', 'review', 'final'] as const +const RESPONSE_SECTIONS = ['translation', 'review', 'final'] as const /** Extract the machine-consumed text fence from `translation-prompt.md`. */ function extractTranslationPrompt(document: string): string { @@ -61,31 +53,15 @@ export function documentedTranslationPromptPlaceholders(document: string): strin return [...document.slice(0, preambleEnd).matchAll(/^\| `{{([a-z_]+)}}` \|/gm)].map(match => match[1] ?? '') } -/** Render one system prompt from the checked-in template and canonical rules. */ +/** Render one system prompt from the checked-in template. */ export function renderTranslationPrompt(document: string, input: TranslationPromptInput): string { - if (basename(input.sourceFilename) !== input.sourceFilename) { - throw new Error(`translation prompt: sourceFilename must be a basename; got ${JSON.stringify(input.sourceFilename)}`) - } - const sourceIsChinese = input.sourceFilename.endsWith('.zh.md') - if (input.sourceLanguage === 'Chinese' ? !sourceIsChinese : sourceIsChinese || !input.sourceFilename.endsWith('.md')) { - throw new Error(`translation prompt: ${input.sourceFilename} does not match source language ${input.sourceLanguage}`) - } - const targetLanguage: TranslationLanguage = input.sourceLanguage === 'English' ? 'Chinese' : 'English' - const sourceFilenameZh = sourceIsChinese ? input.sourceFilename : input.sourceFilename.replace(/\.md$/, '.zh.md') const values: Record = { source_lang: input.sourceLanguage, target_lang: targetLanguage, - translation_rules: input.translationRules, terminology: input.terminology, - source_filename: input.sourceFilename, - source_filename_zh: sourceFilenameZh, } const template = extractTranslationPrompt(document) - const placeholderFreeTemplate = template.replace(PLACEHOLDER, '') - if (placeholderFreeTemplate.includes('{{') || placeholderFreeTemplate.includes('}}')) { - throw new Error('translation prompt: template contains malformed placeholder syntax') - } const names = [...template.matchAll(PLACEHOLDER)].map(match => match[1] ?? '') const unknown = names.filter(name => !TRANSLATION_PROMPT_PLACEHOLDERS.includes(name as TranslationPromptPlaceholder)) if (unknown.length > 0) throw new Error(`translation prompt: unsupported placeholder(s): ${[...new Set(unknown)].join(', ')}`) @@ -95,77 +71,37 @@ export function renderTranslationPrompt(document: string, input: TranslationProm return template.replace(PLACEHOLDER, (_token, name: string) => values[name as TranslationPromptPlaceholder]) } -/** Escape one value so it remains byte-identical inside an XML CDATA field. */ -function escapeTranslationCdata(value: string): string { - return value.replaceAll(']]>', ']]]]>') -} - -/** Serialize a response using the exact XML wire contract in the prompt. */ +/** Serialize a response in the exact three-section shape the prompt requests. */ export function renderTranslationResponse(response: TranslationResponse): string { - return [ - '', - ``, - ``, - ``, - '', - ].join('\n') + return RESPONSE_SECTIONS.map(section => `<${section}>\n${response[section]}\n`).join('\n\n') } -/** Parse and validate the exact XML response shape emitted by the model. */ -export function parseTranslationResponse(xml: string): TranslationResponse { - const values: TranslationResponse = { translation: '', review: '', final: '' } - const stack: string[] = [] - const cdataFields = new Set() - let rootSeen = false - let childIndex = 0 - const fail = (message: string): never => { - throw new Error(`translation response: ${message}`) - } - const parser = new SaxesParser({ xmlns: false }) +/** + * Parse the three-section response. Sections must each appear exactly once + * and in order; bodies are raw Markdown taken verbatim between the tags. + * A fenced ```xml wrapper around the whole response is tolerated, matching + * the shape some models echo back from the prompt's own example. + */ +export function parseTranslationResponse(text: string): TranslationResponse { + let body = text.trim() + const fenced = /^```(?:xml)?\n([\s\S]*?)\n```$/.exec(body) + if (fenced?.[1] !== undefined) body = fenced[1].trim() - parser.on('opentag', (tag) => { - if (stack.length === 0) { - if (rootSeen) fail('contains more than one root element') - if (tag.name !== 'dsh-translation-response') fail(`expected dsh-translation-response root, got ${tag.name}`) - const attributes = Object.keys(tag.attributes) - if (attributes.length !== 1 || tag.attributes.version !== '1') fail('root must have only version="1"') - rootSeen = true - } else if (stack.length === 1) { - const expected = RESPONSE_CHILDREN[childIndex] - if (tag.name !== expected) fail(`expected ${expected ?? 'no more children'}, got ${tag.name}`) - if (Object.keys(tag.attributes).length !== 0) fail(`${tag.name} must not have attributes`) - childIndex++ - } else { - fail(`nested element ${tag.name} is not allowed`) - } - stack.push(tag.name) - }) - parser.on('text', (value) => { - if (stack.length <= 1 && value.trim() === '') return - fail('all response field content must be inside CDATA') - }) - parser.on('cdata', (value) => { - const field = stack.at(-1) - if (field === undefined || !RESPONSE_CHILDREN.includes(field as (typeof RESPONSE_CHILDREN)[number])) { - fail('CDATA is allowed only inside translation, review, or final') - } - const key = field as (typeof RESPONSE_CHILDREN)[number] - values[key] += value - cdataFields.add(key) - }) - parser.on('closetag', (tag) => { - const expected = stack.pop() - if (expected !== tag.name) fail(`closing ${tag.name} does not match ${expected ?? 'nothing'}`) - }) - parser.on('comment', () => fail('comments are not allowed')) - parser.on('doctype', () => fail('doctypes are not allowed')) - parser.on('processinginstruction', () => fail('processing instructions are not allowed')) - parser.on('error', error => fail(`invalid XML: ${error.message}`)) - parser.write(xml).close() - - if (childIndex !== RESPONSE_CHILDREN.length) fail('translation, review, and final must each appear exactly once and in order') - for (const field of RESPONSE_CHILDREN) { - if (!cdataFields.has(field)) fail(`${field} must contain a CDATA section`) + const values: Partial> = {} + let cursor = 0 + for (const section of RESPONSE_SECTIONS) { + const open = `<${section}>` + const close = `` + const start = body.indexOf(open, cursor) + if (start === -1) throw new Error(`translation response: missing <${section}> section`) + const end = body.indexOf(close, start + open.length) + if (end === -1) throw new Error(`translation response: unterminated <${section}> section`) + values[section] = body.slice(start + open.length, end).replace(/^\n/, '').replace(/\n$/, '') + cursor = end + close.length } - return values + for (const section of RESPONSE_SECTIONS) { + const again = body.indexOf(`<${section}>`, cursor) + if (again !== -1) throw new Error(`translation response: duplicate <${section}> section`) + } + return values as TranslationResponse } diff --git a/scripts/verify-translation-prompt.ts b/scripts/verify-translation-prompt.ts index 66d83e47ad..df72ac73ce 100644 --- a/scripts/verify-translation-prompt.ts +++ b/scripts/verify-translation-prompt.ts @@ -18,37 +18,27 @@ function read(path: string): string { try { const document = read('docs/i18n/translation-prompt.md') - const translationRules = read('docs/i18n/translation-rules.md') const terminology = read('docs/i18n/terminology.md') const documented = documentedTranslationPromptPlaceholders(document) if (documented.join('\n') !== TRANSLATION_PROMPT_PLACEHOLDERS.join('\n')) { throw new Error(`placeholder table must list exactly: ${TRANSLATION_PROMPT_PLACEHOLDERS.join(', ')}`) } - const englishSource = renderTranslationPrompt(document, { - sourceLanguage: 'English', - sourceFilename: 'example.md', - translationRules, - terminology, - }) - const chineseSource = renderTranslationPrompt(document, { - sourceLanguage: 'Chinese', - sourceFilename: 'example.zh.md', - translationRules, - terminology, - }) - if (!englishSource.includes('[English](example.md) | 中文')) throw new Error('English-source render does not carry the Chinese switcher instruction') - if (!chineseSource.includes('English | [中文](example.zh.md)')) throw new Error('Chinese-source render does not carry the English switcher instruction') + const englishSource = renderTranslationPrompt(document, { sourceLanguage: 'English', terminology }) + const chineseSource = renderTranslationPrompt(document, { sourceLanguage: 'Chinese', terminology }) + if (englishSource.includes('{{') || chineseSource.includes('{{')) throw new Error('rendered prompt contains an unresolved placeholder') + if (!englishSource.includes('from English to Chinese')) throw new Error('English-source render does not translate into Chinese') + if (!chineseSource.includes('from Chinese to English')) throw new Error('Chinese-source render does not translate into English') const example = /```xml\n([\s\S]*?)\n```/.exec(englishSource)?.[1] - if (example === undefined) throw new Error('rendered prompt has no XML response example') + if (example === undefined) throw new Error('rendered prompt has no three-section response example') parseTranslationResponse(example) - const roundTrip = { translation: 'first ]]> pass', review: '- [None] No corrections.', final: 'final ]]> text' } + const roundTrip = { translation: 'first pass\n\nwith **markdown**', review: '- 无修正', final: 'final text' } const parsed = parseTranslationResponse(renderTranslationResponse(roundTrip)) - if (JSON.stringify(parsed) !== JSON.stringify(roundTrip)) throw new Error('CDATA split rule does not round-trip response content') + if (JSON.stringify(parsed) !== JSON.stringify(roundTrip)) throw new Error('three-section response does not round-trip') - console.log('verify-translation-prompt: both directions render and the XML response contract parses.') + console.log('verify-translation-prompt: both directions render and the three-section response contract parses.') } catch (error) { const message = error instanceof Error ? error.message : String(error) console.error(`verify-translation-prompt: ${message}`) From 64d70670e8e5fe0d36151f3ad6cb4f7d20b96a7d Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:46:46 -0700 Subject: [PATCH 02/39] docs(i18n): address ds-review-bot on the v4 restoration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 解析器:三段闭合标签改为行首锚定(正文提及 不再截 断)、重复段全文扫描并校验段序(补两条回归测试)。切换行:v4 模板 保持字面占位不变,资产文档写明全新配对由流水线在解析后按目标文件 名机械插入、配对门禁兜底。加粗后空格限定于字母/数字/汉字、标点前 一律不加;RFC 2119 关键词改为保留源侧强调标记(斜体归斜体、加粗 归加粗)。i18n README 双侧同步 v4 契约描述(不再承诺 CDATA 协议与 规则注入)并重录配对。 --- docs/i18n/README.i18n.yaml | 4 ++-- docs/i18n/README.md | 2 +- docs/i18n/README.zh.md | 2 +- docs/i18n/translation-prompt.md | 6 ++++-- scripts/translation-prompt.spec.ts | 14 ++++++++++++-- scripts/translation-prompt.ts | 24 ++++++++++++------------ 6 files changed, 32 insertions(+), 20 deletions(-) diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index ab1c9024ad..a1ff3a701e 100644 --- a/docs/i18n/README.i18n.yaml +++ b/docs/i18n/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 -README.md: 17bb1eeb67b4f5119a698fca23f12490c9378a7f -README.zh.md: c957a82bf420a942e2249942a2d9afc54ad950cf +README.md: 3980aef52545aeb7c8ec44856cb95c7c0f7c7f22 +README.zh.md: 39e03cb6d33c008d2f310915db6b6bebd638cded diff --git a/docs/i18n/README.md b/docs/i18n/README.md index 17bb1eeb67..3980aef525 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -49,4 +49,4 @@ The gate's limit, stated plainly: **a green gate means the pair was confirmed co ## Division of labor -Counterparts here are produced by an agent running [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) and reviewed by a human — inference is cheap here, review attention is the scarce resource. The gate checks pair completeness, recorded hashes, switchers, and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the canonical rules into either direction and strictly parses the three-field XML response, while `verify-translation-prompt` exercises both render directions, the checked-in example, and the CDATA split rule in `doc-sync`. +Counterparts here are produced by an agent running [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) and reviewed by a human — inference is cheap here, review attention is the scarce resource. The gate checks pair completeness, recorded hashes, switchers, and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`. diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index c957a82bf4..39e03cb6d3 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -49,4 +49,4 @@ ## 分工 -对侧译文由运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 的 agent 生成,再由人评审:在这里推理(inference)很便宜,评审注意力才是稀缺资源。门禁负责检查配对是否完整、记录的 hash、语言切换行以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。prompt 契约也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把权威规则渲染到英译中或中译英的 prompt 中,并严格解析包含三个字段的 XML 响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向、仓库内示例与 CDATA 拆分规则。 +对侧译文由运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 的 agent 生成,再由人评审:在这里推理(inference)很便宜,评审注意力才是稀缺资源。门禁负责检查配对是否完整、记录的 hash、语言切换行以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。prompt 契约也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把进仓模板(注入术语表;模板自带经人工校准的规则)渲染到英译中或中译英的 prompt 中,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。 diff --git a/docs/i18n/translation-prompt.md b/docs/i18n/translation-prompt.md index d59170b00a..91b52bbb51 100644 --- a/docs/i18n/translation-prompt.md +++ b/docs/i18n/translation-prompt.md @@ -14,6 +14,8 @@ 流水线只识别上表中的占位符,并且一次翻译整篇文档。它不支持 `{{to}}`、`{{title_prompt}}`、`{{summary_prompt}}`、`{{terms_prompt}}`、`{{imt_style_guide}}`、`{{translation_rules}}` 或 `%%` 分段协议;输出采用模板正文规定的三段 XML,流水线解析取 `` 段。 +语言切换行:已有配对的源文件自带切换行,模型按模板规则翻转即可。全新配对的源文件没有切换行,模型也无从得知文件名——此时由流水线在解析 `` 后按目标文件名插入或校正切换行(机械后处理,配对门禁兜底校验)。 + ## Few-shot 金标 流水线使用**整篇文档**的中英对照作为 few-shot,不是模板内嵌的句子级正误例。以下 5 组配对文档均经过人工评审,以仓库当前版本为准、随仓库更新: @@ -41,7 +43,7 @@ You are a senior technical translator specializing in LLM and agent development - Inline code spans (commands, flags, paths, API names, version numbers) must be kept verbatim. Never translate or reformat them. - Every relative link must point to the same target as in the source. Link text is translated; link targets are not. - Language switcher line: when translating into Chinese, write `[English](source-filename.md) | 中文`. When translating into English, write `English | [中文](source-filename.zh.md)`. Do NOT copy the switcher line from the source file unchanged — you must flip the link direction. -- After a closing bold marker `**`, always insert a space before the next character. +- After a closing bold marker `**`, insert a space before the next character when that character is a Latin letter, digit, or CJK ideograph. Never insert a space before any punctuation (full-width or half-width). ### Tone and Style - The translation must read as if originally written in the target language by a native speaker. If an expression sounds like a word-for-word rendering from the source language, rephrase it. @@ -73,7 +75,7 @@ You are a senior technical translator specializing in LLM and agent development - Use enumeration commas (、) between parallel items, not regular commas. - List item endings: use semicolons or no punctuation. Do not end list items with commas. - Put one half-width space between Chinese text and Latin words/numbers. -- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), render the corresponding Chinese term in italics: *必须*、*禁止*、*应当*、*可以*. +- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以) and keep the SOURCE emphasis marker: plain source stays plain in italics (*必须*), bold source stays bold (**必须**). #### When translating into English (To be added.) diff --git a/scripts/translation-prompt.spec.ts b/scripts/translation-prompt.spec.ts index db3f8b31a0..45ba97b8e7 100644 --- a/scripts/translation-prompt.spec.ts +++ b/scripts/translation-prompt.spec.ts @@ -42,9 +42,19 @@ describe('translation response sections', () => { expect(parseTranslationResponse(fenced).final).toBe('A') }) + it('keeps an inline close tag inside prose from terminating the section', () => { + const doc = { translation: 'the wire format uses as its close tag', review: '- 无修正', final: 'F' } + expect(parseTranslationResponse(renderTranslationResponse(doc))).toEqual(doc) + }) + + it('rejects a duplicate section appearing before final', () => { + const early = '\nA\n\n\nB\n\n\nR\n\n\nF\n' + expect(() => parseTranslationResponse(early)).toThrow(/duplicate /) + }) + it('rejects missing, unterminated, or duplicated sections', () => { - expect(() => parseTranslationResponse('\nA\n')).toThrow(/missing /) - expect(() => parseTranslationResponse('\nA')).toThrow(/unterminated /) + expect(() => parseTranslationResponse('\nA\n')).toThrow(/missing or unterminated /) + expect(() => parseTranslationResponse('\nA')).toThrow(/missing or unterminated /) const dup = '\nA\n\n\nR\n\n\nF\n\n\nG\n' expect(() => parseTranslationResponse(dup)).toThrow(/duplicate /) }) diff --git a/scripts/translation-prompt.ts b/scripts/translation-prompt.ts index 0556ff39fa..25f1d9ae09 100644 --- a/scripts/translation-prompt.ts +++ b/scripts/translation-prompt.ts @@ -81,6 +81,10 @@ export function renderTranslationResponse(response: TranslationResponse): string * and in order; bodies are raw Markdown taken verbatim between the tags. * A fenced ```xml wrapper around the whole response is tolerated, matching * the shape some models echo back from the prompt's own example. + * + * Section close tags are matched at line starts (the wire shape the prompt + * example establishes), so a tag mentioned inline in translated prose does + * not terminate its section early. */ export function parseTranslationResponse(text: string): TranslationResponse { let body = text.trim() @@ -88,20 +92,16 @@ export function parseTranslationResponse(text: string): TranslationResponse { if (fenced?.[1] !== undefined) body = fenced[1].trim() const values: Partial> = {} - let cursor = 0 for (const section of RESPONSE_SECTIONS) { - const open = `<${section}>` - const close = `` - const start = body.indexOf(open, cursor) - if (start === -1) throw new Error(`translation response: missing <${section}> section`) - const end = body.indexOf(close, start + open.length) - if (end === -1) throw new Error(`translation response: unterminated <${section}> section`) - values[section] = body.slice(start + open.length, end).replace(/^\n/, '').replace(/\n$/, '') - cursor = end + close.length + const pattern = new RegExp(`^<${section}>\\n?([\\s\\S]*?)\\n?^$`, 'gm') + const first = pattern.exec(body) + if (first?.[1] === undefined) throw new Error(`translation response: missing or unterminated <${section}> section`) + if (pattern.exec(body) !== null) throw new Error(`translation response: duplicate <${section}> section`) + values[section] = first[1] } - for (const section of RESPONSE_SECTIONS) { - const again = body.indexOf(`<${section}>`, cursor) - if (again !== -1) throw new Error(`translation response: duplicate <${section}> section`) + const order = RESPONSE_SECTIONS.map(section => body.search(new RegExp(`^<${section}>`, 'm'))) + if (!(order[0]! < order[1]! && order[1]! < order[2]!)) { + throw new Error('translation response: sections must appear in translation, review, final order') } return values as TranslationResponse } From a9ea193e31adba8dfc3418bb1ee0822300c37dfc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:43:53 +0800 Subject: [PATCH 03/39] feat(web): render durable session titles --- apps/web/tests/session-title.snapshot.ts | 114 ++++++++++++++++++ apps/web/tests/snapshots/session-title.json | 12 ++ knip.json | 2 +- .../client/connection/src/client/fixture.ts | 34 +++++- .../client/connection/tests/fixture.spec.ts | 13 +- .../runtime/src/client/sessions/lineage.ts | 18 ++- .../runtime/src/client/sessions/manager.ts | 33 ++++- .../runtime/src/client/sessions/service.ts | 15 ++- packages/client/runtime/tests/manager.spec.ts | 30 +++++ .../runtime/tests/sessions-service.spec.ts | 18 ++- .../src/client/skeleton/ConversationRoot.tsx | 2 +- .../tests/apply-inject.spec.tsx | 4 +- .../ui-conversation/tests/chat-apply.spec.tsx | 4 +- .../tests/chat-stats-bash-sample.spec.tsx | 4 +- .../tests/gate-branch-tails.spec.tsx | 6 +- .../tests/selection-survival.spec.ts | 12 +- .../tests/skeleton-branches.spec.tsx | 2 +- .../ui-conversation/tests/skeleton.spec.tsx | 4 +- .../client/ui-layout/tests/service.spec.ts | 2 +- packages/client/ui-sidebar/src/client/tree.ts | 8 +- .../client/ui-sidebar/tests/apply.spec.tsx | 4 +- .../ui-sidebar/tests/sidebar-root.spec.tsx | 1 + .../client/ui-sidebar/tests/store.spec.ts | 1 + packages/client/ui-sidebar/tests/tree.spec.ts | 13 +- .../client/ui-trajectory/tests/views.spec.tsx | 2 +- packages/client/web/src/DocumentTitle.tsx | 22 ++++ packages/client/web/src/app.tsx | 7 ++ packages/client/web/src/index.ts | 1 + packages/client/web/tests/boot.spec.tsx | 8 +- .../client/web/tests/document-title.spec.tsx | 28 +++++ .../host/apiproxy/src/api/events.schema.ts | 1 + packages/host/apiproxy/src/api/events.ts | 6 +- .../host/apiproxy/tests/rpc-schemas.spec.ts | 8 ++ packages/host/runtime/package.json | 1 + packages/host/runtime/src/api-proxy.ts | 31 ++++- .../host/runtime/tests/host-runtime.spec.ts | 60 +++++++++ packages/host/runtime/tsconfig.json | 3 + pnpm-lock.yaml | 3 + vitest.snapshot.config.ts | 1 + 39 files changed, 481 insertions(+), 57 deletions(-) create mode 100644 apps/web/tests/session-title.snapshot.ts create mode 100644 apps/web/tests/snapshots/session-title.json create mode 100644 packages/client/web/src/DocumentTitle.tsx create mode 100644 packages/client/web/tests/document-title.spec.tsx diff --git a/apps/web/tests/session-title.snapshot.ts b/apps/web/tests/session-title.snapshot.ts new file mode 100644 index 0000000000..a1b5f45839 --- /dev/null +++ b/apps/web/tests/session-title.snapshot.ts @@ -0,0 +1,114 @@ +// @vitest-environment jsdom +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { BootPluginEntry } from '@deepseek-ai/dsh-client-runtime/client' +import { bootWebShell } from '@deepseek-ai/dsh-client-web' + +const PLUGINS: readonly (BootPluginEntry & { dir: string })[] = [ + { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', url: '/plugins/i18n.js', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', inject: ['@deepseek-ai/dsh-client-runtime'] }, + { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, +] + +const bundles = new Map(PLUGINS.map(plugin => [ + plugin.url, + readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'), +])) + +interface FixtureTiming { + appendTitle(id: string, title: string): void +} + +interface FixtureWindow extends Window { + __DSH_BOOT__?: { plugins: BootPluginEntry[] } + DSHClientProxy?: unknown +} + +class ResizeObserverStub { + observe(): void {} + disconnect(): void {} + unobserve(): void {} +} + +const win = window as FixtureWindow +let unmount: (() => void) | undefined + +beforeEach(() => { + localStorage.clear() + history.replaceState(null, '', '/?fixture') + document.title = 'DeepSeek Harness' + const root = document.createElement('div') + root.id = 'root' + document.body.appendChild(root) + vi.stubGlobal('ResizeObserver', ResizeObserverStub) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + setTimeout(() => { callback(0) }, 0) as unknown as number) + vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) }) + win.__DSH_BOOT__ = { plugins: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) } +}) + +afterEach(() => { + act(() => { unmount?.() }) + unmount = undefined + cleanup() + delete win.__DSH_BOOT__ + delete win.DSHClientProxy + delete (globalThis as Record).__fxTiming + document.body.innerHTML = '' + document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() }) + document.title = '' + history.replaceState(null, '', '/') + vi.unstubAllGlobals() +}) + +/** Read only the stable, user-facing title surfaces from the assembled app. */ +function titleSurfaces(label: string): { sidebar: string; breadcrumb: string; documentTitle: string } { + const tree = screen.getByRole('tree', { name: 'Sessions' }) + const sidebar = within(tree).getByText(label).textContent ?? '' + const breadcrumb = within(screen.getByRole('navigation', { name: '会话层级' })) + .getByRole('button', { name: label }).textContent ?? '' + return { sidebar, breadcrumb, documentTitle: document.title } +} + +it('projects initial and revised durable titles through the built eight-plugin fixture app', async () => { + const root = document.querySelector('#root') + if (root === null) throw new Error('snapshot root missing') + act(() => { + unmount = bootWebShell(root, { + fetchBundle: (url) => { + const code = bundles.get(url) + return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code) + }, + executeBundle: (code) => { (0, eval)(code) }, + }) + }) + + const projectLabel = await screen.findByText('fixture', {}, { timeout: 10_000 }) + const projectRow = projectLabel.closest('[role="treeitem"]') + if (projectRow === null) throw new Error('fixture project row missing') + fireEvent.click(projectRow) + + const initialLabel = 'Fixture 历史会话' + const initialRowLabel = await screen.findByText(initialLabel) + const initialRow = initialRowLabel.closest('[role="treeitem"]') + if (initialRow === null) throw new Error('fixture session row missing') + fireEvent.click(initialRow) + await waitFor(() => { expect(document.title).toBe(`${initialLabel} — DeepSeek Harness`) }) + const initial = titleSurfaces(initialLabel) + + const revisedLabel = 'Fixture 修订标题' + const timing = (globalThis as Record).__fxTiming as FixtureTiming + act(() => { timing.appendTitle('fx-alpha', revisedLabel) }) + await waitFor(() => { expect(document.title).toBe(`${revisedLabel} — DeepSeek Harness`) }) + const revised = titleSurfaces(revisedLabel) + + await expect(`${JSON.stringify({ initial, revised }, null, 2)}\n`) + .toMatchFileSnapshot('./snapshots/session-title.json') +}) diff --git a/apps/web/tests/snapshots/session-title.json b/apps/web/tests/snapshots/session-title.json new file mode 100644 index 0000000000..2063036803 --- /dev/null +++ b/apps/web/tests/snapshots/session-title.json @@ -0,0 +1,12 @@ +{ + "initial": { + "sidebar": "Fixture 历史会话", + "breadcrumb": "Fixture 历史会话", + "documentTitle": "Fixture 历史会话 — DeepSeek Harness" + }, + "revised": { + "sidebar": "Fixture 修订标题", + "breadcrumb": "Fixture 修订标题", + "documentTitle": "Fixture 修订标题 — DeepSeek Harness" + } +} diff --git a/knip.json b/knip.json index dbc1e585de..e89f1908fe 100644 --- a/knip.json +++ b/knip.json @@ -545,6 +545,7 @@ "apps/web": { "entry": [ "tests/**/*.e2e.ts", + "tests/**/*.snapshot.ts", "tests/support.ts" ], "project": [ @@ -552,7 +553,6 @@ "tests/**/*.ts" ], "ignoreDependencies": [ - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-primitives", "@deepseek-ai/dsh-client-ui-slots", "@deepseek-ai/dsh-client-web-react", diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 7d4a93e888..f831519e25 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -40,7 +40,13 @@ function buildAlphaLog(): SessionEvent[] { } for (let turn = 0; turn < 60; turn++) { push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) - push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}:fixture 历史消息,用于翻页与渲染验收。`), source: { kind: 'user' } } }) + const userSeq = push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}:fixture 历史消息,用于翻页与渲染验收。`), source: { kind: 'user' } } }) + if (turn === 0) { + push({ + type: 'session/title', + data: { title: 'Fixture 历史会话', messageSeqs: [userSeq], source: { kind: 'fallback' } }, + }) + } if (turn % 9 === 4) { push({ type: 'context/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入(turn ${turn})`), source: { kind: 'plugin', plugin: 'fixture' } } }) } @@ -155,6 +161,20 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi return undefined } +/** Fold the latest fixture title into the host's control-frame projection. */ +function titleFrameOf(id: SessionId, log: readonly SessionEvent[]): Extract | undefined { + const event = log.findLast(item => (item as { type: string }).type === 'session/title') + if (event === undefined) return undefined + const titleEvent = event as unknown as { seq: number; time: number; data: { title: string } } + return { + type: 'session/title', + sessionId: id, + title: titleEvent.data.title, + eventSeq: titleEvent.seq, + updatedAt: titleEvent.time, + } +} + /** * Message-boundary paging (mirrors the host's paging contract): count * maxMessages messages @@ -294,6 +314,10 @@ export function createFixtureApi(): ApiProxy { emitMux(view === undefined ? { type: 'session/event', sessionId: id, event } : { type: 'session/event', sessionId: id, event, view }) + if ((event as { type: string }).type === 'session/title') { + // The raw title is already in this log, so the latest-title fold must find it. + emitMux(titleFrameOf(id, log) as Extract) + } } /** At most one in-flight replay per session; cancel clears it. */ @@ -322,6 +346,12 @@ export function createFixtureApi(): ApiProxy { appendUser(id: string, msg: string): void { append(sid(id), { type: 'user/message', surfaceOp: 'append', data: { content: text(msg), source: { kind: 'user' } } }) }, + /** Append a later durable title revision through the normal raw-event + control-frame path. */ + appendTitle(id: string, title: string): void { + const log = logOf(sid(id)) + const messageSeqs = log.filter(event => event.type === 'user/message').map(event => event.seq) + append(sid(id), { type: 'session/title', data: { title, messageSeqs, source: { kind: 'provider', provider: 'fixture' } } }) + }, /** Log append WITHOUT the mux emit: a frame lost in transit — history still serves it, the client must repull. */ appendSilent(id: string, msg: string): void { const log = logOf(sid(id)) @@ -433,6 +463,8 @@ export function createFixtureApi(): ApiProxy { for (const s of sessions) { if (!s.running) continue conn.push({ rpcId: mint(), payload: { type: 'session/subscribed', sessionId: s.sessionId, lastSeq: (logs.get(s.sessionId)?.length ?? 0) - 1 } }) + const title = titleFrameOf(s.sessionId, logs.get(s.sessionId) ?? []) + if (title !== undefined) conn.push({ rpcId: mint(), payload: title }) } conn.push({ rpcId: pendingApprovalRpcId, diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index c50921a44d..440c90fc05 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -18,6 +18,7 @@ interface TimingHooks { setHistoryDelay(ms: number): void failNextHistory(): void appendUser(id: string, msg: string): void + appendTitle(id: string, title: string): void appendSilent(id: string, msg: string): void breakStreams(): void } @@ -155,7 +156,7 @@ describe('createFixtureApi', () => { const envelopes: RpcRequest[] = [] for await (const envelope of api.events.mux(req({}), abort.signal)) { envelopes.push(envelope) - if (envelopes.length >= 2) abort.abort() + if (envelopes.length >= 3) abort.abort() } return envelopes } @@ -163,8 +164,9 @@ describe('createFixtureApi', () => { const second = await openOnce() expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' }) expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0) - expect(first[1]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' }) - expect(second[1]?.rpcId).toBe(first[1]?.rpcId) // stable rpcId across replays (host replay semantics) + expect(first[1]?.payload).toMatchObject({ type: 'session/title', sessionId: 'fx-alpha', title: 'Fixture 历史会话' }) + expect(first[2]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' }) + expect(second[2]?.rpcId).toBe(first[2]?.rpcId) // stable rpcId across replays (host replay semantics) }) it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => { @@ -248,10 +250,15 @@ describe('createFixtureApi', () => { await new Promise(resolve => setTimeout(resolve, 10)) hooks.appendSilent('fx-alpha', '静默丢帧') hooks.appendUser('fx-alpha', '正常直播') + hooks.appendTitle('fx-alpha', 'Fixture 修订标题') await vi.waitFor(() => { expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('正常直播'))).toBe(true) + expect(seen.some(f => f.type === 'session/title' && f.title === 'Fixture 修订标题')).toBe(true) }) expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('静默丢帧'))).toBe(false) + const rawTitleIndex = seen.findIndex(f => f.type === 'session/event' && (f.event as { type: string }).type === 'session/title') + const titleControlIndex = seen.findIndex(f => f.type === 'session/title' && f.title === 'Fixture 修订标题') + expect(titleControlIndex).toBe(rawTitleIndex + 1) // But history serves the silent event (the client's repull finds it). const repull = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 5 })) if (!repull.result.ok) throw new Error('repull failed') diff --git a/packages/client/runtime/src/client/sessions/lineage.ts b/packages/client/runtime/src/client/sessions/lineage.ts index 4f67f33343..c6bd572ea7 100644 --- a/packages/client/runtime/src/client/sessions/lineage.ts +++ b/packages/client/runtime/src/client/sessions/lineage.ts @@ -4,9 +4,15 @@ import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client' +/** Host list summary enriched with the latest mux-projected durable title. */ +export interface TitledSessionSummary extends SessionSummary { + title?: string +} + /** One flattened session-list row (summary + lineage indent depth). */ export interface SessionListEntry { sessionId: SessionId + title?: string updatedAt: number running: boolean parentSessionId?: SessionId @@ -21,12 +27,12 @@ export interface SessionListEntry { * @param summaries - the host's session.list items. * @returns display rows in render order. */ -export function flattenLineage(summaries: readonly SessionSummary[]): SessionListEntry[] { - const byId = new Map() +export function flattenLineage(summaries: readonly TitledSessionSummary[]): SessionListEntry[] { + const byId = new Map() for (const s of summaries) byId.set(s.sessionId, s) - const children = new Map() - const roots: SessionSummary[] = [] + const children = new Map() + const roots: TitledSessionSummary[] = [] for (const s of summaries) { if (s.parentSessionId !== undefined && byId.has(s.parentSessionId)) { const list = children.get(s.parentSessionId) ?? [] @@ -37,12 +43,12 @@ export function flattenLineage(summaries: readonly SessionSummary[]): SessionLis } } - const byUpdatedDesc = (a: SessionSummary, b: SessionSummary): number => b.updatedAt - a.updatedAt + const byUpdatedDesc = (a: TitledSessionSummary, b: TitledSessionSummary): number => b.updatedAt - a.updatedAt roots.sort(byUpdatedDesc) const out: SessionListEntry[] = [] const visited = new Set() - const walk = (s: SessionSummary, depth: number): void => { + const walk = (s: TitledSessionSummary, depth: number): void => { if (visited.has(s.sessionId)) { console.warn(`[web-runtime] lineage cycle at ${s.sessionId}; emitting as root`) return diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 2881fea468..950b03b517 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -4,7 +4,7 @@ import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client' import { transportError } from '@deepseek-ai/dsh-client-connection/client' -import type { SessionListEntry } from './lineage.ts' +import type { SessionListEntry, TitledSessionSummary } from './lineage.ts' import { flattenLineage } from './lineage.ts' import { Notifier } from './notifier.ts' import { Session } from './session.ts' @@ -19,6 +19,13 @@ export interface SessionListSnapshot { /** Per-session cap for pre-instantiation approval/question buffering (low-frequency frames; a few dozen covers any real backlog). */ const PENDING_BUFFER_CAP = 32 +/** Latest title control snapshot retained independently of list/instance arrival. */ +interface SessionTitleSnapshot { + title: string + eventSeq: number + updatedAt: number +} + /** Instance cluster + frame entry + the session list (see the web client architecture RFC). */ export class SessionManager { private readonly sessions = new Map() @@ -27,6 +34,7 @@ export class SessionManager { * drop-and-backfill path; replayed and cleared on instantiation. Bounded per session (these * frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */ private readonly pendingBuffers = new Map[]>() + private readonly titleSnapshots = new Map() private summaries: SessionSummary[] = [] private listState: 'idle' | 'loading' | 'error' = 'idle' private listError: RpcError | null = null @@ -158,6 +166,17 @@ export class SessionManager { handleMuxEnvelope(envelope: RpcRequest): void { const frame = envelope.payload if (frame.type === 'stream/error') return // Controller already treats this as stream failure + if (frame.type === 'session/title') { + const current = this.titleSnapshots.get(frame.sessionId) + if (current !== undefined && current.eventSeq >= frame.eventSeq) return + this.titleSnapshots.set(frame.sessionId, { + title: frame.title, + eventSeq: frame.eventSeq, + updatedAt: frame.updatedAt, + }) + this.notifier.markDirty() + return + } const session = this.sessions.get(frame.sessionId) if (session === undefined) { // Approval/question frames never hit history: buffer for replay on instantiation; @@ -204,6 +223,7 @@ export class SessionManager { this.summaries = this.summaries.filter(s => s.sessionId !== frame.sessionId) this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation + this.titleSnapshots.delete(frame.sessionId) this.notifier.markDirty() return } @@ -230,12 +250,19 @@ export class SessionManager { } private buildListSnapshot(): SessionListSnapshot { - const fresh = flattenLineage(this.summaries) + const merged: TitledSessionSummary[] = this.summaries.map((summary) => { + const title = this.titleSnapshots.get(summary.sessionId) + return title === undefined + ? summary + : { ...summary, title: title.title, updatedAt: Math.max(summary.updatedAt, title.updatedAt) } + }) + const fresh = flattenLineage(merged) const items = fresh.map((entry) => { const prev = this.entryCache.get(entry.sessionId) if ( prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running - && prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd && prev.depth === entry.depth + && prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd + && prev.title === entry.title && prev.depth === entry.depth ) return prev this.entryCache.set(entry.sessionId, entry) return entry diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 2b4cf9677e..25c6579c3f 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -21,7 +21,10 @@ import type { Session } from './session.ts' /** Session list row projected from the host list RPC plus live stream increments. */ export interface SessionSummary { id: SessionId - title: string + /** Latest durable log-backed title, absent until the host projects one. */ + title?: string + /** Human-facing label: durable title, project basename, then session id. */ + displayTitle: string cwd?: string parentId?: SessionId running: boolean @@ -54,10 +57,11 @@ export function scopeOf(ctx: Context): SessionId | undefined { function sessionScope(): void {} /** - * Display title projection. The wire summary carries no title yet (P-I - * ledger): the project directory's basename stands in, then the raw id. + * Display title projection: durable title, project directory basename, then + * the raw id. */ -function titleOf(cwd: string | undefined, id: SessionId): string { +function displayTitleOf(title: string | undefined, cwd: string | undefined, id: SessionId): string { + if (title !== undefined) return title if (cwd !== undefined && cwd !== '') { const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() if (base !== undefined && base !== '') return base @@ -176,9 +180,10 @@ export class SessionsService { ids.push(entry.sessionId) byId[entry.sessionId] = { id: entry.sessionId, - title: titleOf(entry.cwd, entry.sessionId), + displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId), running: entry.running, updatedAt: entry.updatedAt, + ...(entry.title !== undefined ? { title: entry.title } : {}), ...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}), ...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}), } diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index af7bc60fd2..edf326bd31 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -89,6 +89,36 @@ describe('list lifecycle', () => { expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } }) expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2]) }) + + it('retains monotonic title snapshots before list arrival, merges recency, and clears them on removal', async () => { + const api = new FakeApiClient() + const manager = new SessionManager(api) + manager.handleMuxEnvelope({ + rpcId: 'title-new' as never, + payload: { type: 'session/title', sessionId: S1, title: 'Newest', eventSeq: 4, updatedAt: 300 }, + }) + manager.handleMuxEnvelope({ + rpcId: 'title-stale' as never, + payload: { type: 'session/title', sessionId: S1, title: 'Stale', eventSeq: 3, updatedAt: 900 }, + }) + manager.handleMuxEnvelope({ + rpcId: 'title-equal' as never, + payload: { type: 'session/title', sessionId: S1, title: 'Equal', eventSeq: 4, updatedAt: 901 }, + }) + api.onList = () => Promise.resolve(ok({ + items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[], + })) + await manager.refreshList() + + const titled = manager.getListSnapshot() + expect(titled.items.map(item => item.sessionId)).toEqual([S1, S2]) + expect(titled.items[0]).toMatchObject({ title: 'Newest', updatedAt: 300 }) + expect(titled.items[1]?.title).toBeUndefined() + + manager.handleHostEnvelope({ rpcId: 'removed' as never, payload: { type: 'host/session-removed', sessionId: S1 } }) + manager.handleHostEnvelope({ rpcId: 'readded' as never, payload: { type: 'host/session-added', sessionId: S1 } }) + expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined() + }) }) describe('host frame routing', () => { diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index f3989c4532..4a8b68ed73 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -38,16 +38,21 @@ async function feedList(b: Bench, rows: { id: string; cwd?: string; parentId?: s } describe('list store projection', () => { - it('projects ids/byId with cwd-basename titles (id fallback) and parent links', async () => { + it('projects durable titles separately from cwd/id display fallbacks and parent links', async () => { const b = bench() + b.svc.manager.handleMuxEnvelope({ + rpcId: 'title' as never, + payload: { type: 'session/title', sessionId: sid('s1'), title: 'Durable title', eventSeq: 2, updatedAt: 3 }, + }) await feedList(b, [ { id: 's1', cwd: '/home/u/proj-a/' }, { id: 's2', parentId: 's1', running: true }, ]) const state = b.svc.list.getSnapshot() expect(state.ids).toEqual(['s1', 's2']) - expect(state.byId[sid('s1')]).toMatchObject({ title: 'proj-a', cwd: '/home/u/proj-a/' }) - expect(state.byId[sid('s2')]).toMatchObject({ title: 's2', parentId: 's1', running: true }) + expect(state.byId[sid('s1')]).toMatchObject({ title: 'Durable title', displayTitle: 'Durable title', cwd: '/home/u/proj-a/' }) + expect(state.byId[sid('s2')]).toMatchObject({ displayTitle: 's2', parentId: 's1', running: true }) + expect(state.byId[sid('s2')]?.title).toBeUndefined() }) it('reflects live increments (host stream via manager) into the store', async () => { @@ -141,12 +146,13 @@ describe('create', () => { }) describe('coverage tails (branch duals)', () => { - it('titleOf falls back to the id for empty and separator-only cwd', async () => { + it('displayTitleOf falls back to the id for empty and separator-only cwd', async () => { const b = bench() await feedList(b, [{ id: 'no-base', cwd: '///' }, { id: 'empty-cwd', cwd: '' }]) const { byId } = b.svc.list.getSnapshot() - expect(byId[sid('no-base')]?.title).toBe('no-base') - expect(byId[sid('empty-cwd')]?.title).toBe('empty-cwd') + expect(byId[sid('no-base')]?.displayTitle).toBe('no-base') + expect(byId[sid('empty-cwd')]?.displayTitle).toBe('empty-cwd') + expect(byId[sid('no-base')]?.title).toBeUndefined() }) it('binding for an unknown session returns undefined without moving the watch', async () => { diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 540b76bda7..3f9aaf8bf1 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -54,7 +54,7 @@ export function ConversationRoot({ disabled={last} onClick={() => { actions.open(s.id) }} > - {s.title} + {s.displayTitle} ) diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 2c0166c7de..b343cf1bca 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -50,7 +50,7 @@ async function bench() { const listStore = createSnapshotStore({ ids: [ROOT], - byId: { [ROOT]: { id: ROOT, title: 'R', cwd: '/proj', running: false, updatedAt: 1 } }, + byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, updatedAt: 1 } }, }) const snap = snapshotBase() const sessionFake = { @@ -208,7 +208,7 @@ describe('conversation slot inject surface', () => { // Ancestry and draft/active-view hooks execute inside a component tree. const HookProbe = () => { const injected2 = b.entryOf('conversation').options.inject(b.binding) as { - useAncestry: () => readonly { title: string }[] + useAncestry: () => readonly { displayTitle: string }[] useActiveView: () => string | undefined composer: { useDraft: () => string } } diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index e6ce83edc5..72d31560b9 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -26,8 +26,8 @@ async function bench() { const listStore = createSnapshotStore({ ids: [ROOT, CHILD], byId: { - [ROOT]: { id: ROOT, title: 'R', running: false, updatedAt: 1 }, - [CHILD]: { id: CHILD, title: 'C', parentId: ROOT, running: false, updatedAt: 2 }, + [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', running: false, updatedAt: 1 }, + [CHILD]: { id: CHILD, title: 'C', displayTitle: 'C', parentId: ROOT, running: false, updatedAt: 2 }, }, }) const sessionsFake = { diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx index 63b76c86df..3870e7eb09 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx @@ -155,8 +155,8 @@ describe('bash toolview samples', () => { getSnapshot: () => ({ ids: [root, child], byId: { - [root]: { id: root, title: 'r', running: false, updatedAt: 0 }, - [child]: { id: child, title: 'c', parentId: root, running: false, updatedAt: 0 }, + [root]: { id: root, title: 'r', displayTitle: 'r', running: false, updatedAt: 0 }, + [child]: { id: child, title: 'c', displayTitle: 'c', parentId: root, running: false, updatedAt: 0 }, }, }), }) diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx index 3ff148f5d6..61aa499b5b 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -49,9 +49,9 @@ describe('apply need() and cwd cache', () => { const listStore = createSnapshotStore({ ids: [SID, 'x2' as SessionId, 'x3' as SessionId], byId: { - [SID]: { id: SID, title: 'a', cwd: '/proj', running: false, updatedAt: 1 }, - ['x2' as SessionId]: { id: 'x2' as SessionId, title: 'b', cwd: '', running: false, updatedAt: 1 }, - ['x3' as SessionId]: { id: 'x3' as SessionId, title: 'c', running: false, updatedAt: 1 }, + [SID]: { id: SID, title: 'a', displayTitle: 'a', cwd: '/proj', running: false, updatedAt: 1 }, + ['x2' as SessionId]: { id: 'x2' as SessionId, title: 'b', displayTitle: 'b', cwd: '', running: false, updatedAt: 1 }, + ['x3' as SessionId]: { id: 'x3' as SessionId, title: 'c', displayTitle: 'c', running: false, updatedAt: 1 }, }, }) ctx.provide('sessions', { list: listStore, manager: { get: vi.fn() }, ancestry: () => [], scope: () => undefined, create: vi.fn() }) diff --git a/packages/client/ui-conversation/tests/selection-survival.spec.ts b/packages/client/ui-conversation/tests/selection-survival.spec.ts index f12cb63617..c1a5846d48 100644 --- a/packages/client/ui-conversation/tests/selection-survival.spec.ts +++ b/packages/client/ui-conversation/tests/selection-survival.spec.ts @@ -49,13 +49,14 @@ function feed(b: Bench, rows: { id: string; cwd?: string; running?: boolean }[]) } describe('selection survives list refreshes (M1a)', () => { - it('create → select → title-upgrading refresh keeps scope, binding, store and value', async () => { + it('create → select → display-title-upgrading refresh keeps scope, binding, store and value', async () => { const b = bench() // First-send shape: client-side create inserts the row without cwd (title = bare id). b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s1') })) const id = await b.sessions.create({}) await flush() - expect(b.sessions.list.getSnapshot().byId[id]?.title).toBe('s1') + expect(b.sessions.list.getSnapshot().byId[id]).toMatchObject({ displayTitle: 's1' }) + expect(b.sessions.list.getSnapshot().byId[id]?.title).toBeUndefined() const binding = b.sessions.binding(id) expect(binding).toBeDefined() @@ -63,11 +64,12 @@ describe('selection survives list refreshes (M1a)', () => { const store = (scoped.get('conversation') as ConversationService).selection store.set({ turnSeq: 3, callId: 'c1' }) - // The late list refresh lands (host knows the cwd → formal title). + // The late list refresh lands (host knows the cwd → better fallback label). feed(b, [{ id: 's1', cwd: '/w/proj-a' }]) await b.sessions.manager.refreshList() await flush() - expect(b.sessions.list.getSnapshot().byId[id]?.title).toBe('proj-a') + expect(b.sessions.list.getSnapshot().byId[id]).toMatchObject({ displayTitle: 'proj-a' }) + expect(b.sessions.list.getSnapshot().byId[id]?.title).toBeUndefined() // Scope, binding and the selection account must all be identity-stable. expect(b.sessions.scope(id)).toBe(scoped) @@ -87,7 +89,7 @@ describe('selection survives list refreshes (M1a)', () => { const store = (scoped.get('conversation') as ConversationService).selection store.set({ turnSeq: 1, callId: 'c9' }) - // Reconnect generation: title upgrade arrives with the re-pull. + // Reconnect generation: display-title fallback upgrade arrives with the re-pull. feed(b, [{ id: 's1', cwd: '/w/proj-a', running: true }]) b.sessions.manager.handleConnected() await flush() diff --git a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx index 25808e17c5..e7db208c17 100644 --- a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx @@ -33,7 +33,7 @@ function sessionSource(over?: Partial) { } const summary = (id: string, title: string): SessionSummary => - ({ id: id as SessionId, title, running: false, updatedAt: 1 }) + ({ id: id as SessionId, title: `durable ${title}`, displayTitle: title, running: false, updatedAt: 1 }) describe('ConversationRoot branches', () => { const chatEntry: ViewEntry = { diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index ab23a64389..d480744a0d 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -76,8 +76,8 @@ describe('ConversationRoot', () => { const send = vi.fn() const stop = vi.fn() const ancestry: SessionSummary[] = [ - { id: sid('root'), title: 'proj', running: false, updatedAt: 1 }, - { id: sid('s1'), title: 'child', running: false, updatedAt: 1, parentId: sid('root') }, + { id: sid('root'), title: 'proj', displayTitle: 'proj', running: false, updatedAt: 1 }, + { id: sid('s1'), title: 'child', displayTitle: 'child', running: false, updatedAt: 1, parentId: sid('root') }, ] const rendered: string[] = [] const ui = render( diff --git a/packages/client/ui-layout/tests/service.spec.ts b/packages/client/ui-layout/tests/service.spec.ts index 85d458cf7b..1e1d13b80c 100644 --- a/packages/client/ui-layout/tests/service.spec.ts +++ b/packages/client/ui-layout/tests/service.spec.ts @@ -20,7 +20,7 @@ function makeCtx() { /** Test-side brand: specs mint ids the wire would normally brand. */ const sid = (s: string): SessionId => s as SessionId -const summary = (id: SessionId) => ({ id, title: id as string, running: false, updatedAt: 1 }) +const summary = (id: SessionId) => ({ id, title: id as string, displayTitle: id as string, running: false, updatedAt: 1 }) beforeEach(() => { localStorage.clear() }) diff --git a/packages/client/ui-sidebar/src/client/tree.ts b/packages/client/ui-sidebar/src/client/tree.ts index 1858643d43..5b855005aa 100644 --- a/packages/client/ui-sidebar/src/client/tree.ts +++ b/packages/client/ui-sidebar/src/client/tree.ts @@ -153,7 +153,7 @@ function sessionRow(g: Group, s: SessionSummary, depth: number, hasChildren: boo type: 'session', id: s.id, groupKey: g.key, - title: s.title, + title: s.displayTitle, depth, hasChildren, expanded, @@ -182,7 +182,7 @@ function flattenVisible(g: Group, expandedSessions: ReadonlySet, rows: S function searchVisible(g: Group, q: string): Set { const visible = new Set() for (const m of g.summaries.values()) { - if (!m.title.toLowerCase().includes(q)) continue + if (!m.displayTitle.toLowerCase().includes(q)) continue let cur: SessionSummary | undefined = m while (cur !== undefined && !visible.has(cur.id)) { visible.add(cur.id) @@ -212,9 +212,9 @@ function flattenSearch(g: Group, visible: ReadonlySet, rows: SidebarR * * Normal mode: every project row shows; sessions show under expanded * projects, descending only into expanded sessions. Search mode (non-blank - * query, case-insensitive title substring): expansion state is ignored — + * query, case-insensitive display-title substring): expansion state is ignored — * matched sessions and their ancestor chains are forced visible, groups - * without a title or label hit are dropped, and a label-only hit keeps the + * without a display-title or label hit are dropped, and a label-only hit keeps the * bare project row. * @param list - sessions list snapshot. * @param view - expansion sets and search query. diff --git a/packages/client/ui-sidebar/tests/apply.spec.tsx b/packages/client/ui-sidebar/tests/apply.spec.tsx index ea029c6709..9e57771a27 100644 --- a/packages/client/ui-sidebar/tests/apply.spec.tsx +++ b/packages/client/ui-sidebar/tests/apply.spec.tsx @@ -28,7 +28,7 @@ async function bench() { await ctx.plugin(SlotsService).await() const list = createSnapshotStore({ ids: [sid('a')], - byId: { [sid('a')]: { id: sid('a'), title: 'alpha', cwd: '/proj', running: false, updatedAt: 1 } }, + byId: { [sid('a')]: { id: sid('a'), title: 'alpha', displayTitle: 'alpha', cwd: '/proj', running: false, updatedAt: 1 } }, }) const sessions = { list, create: vi.fn(async () => sid('minted')) } const layout = { @@ -132,7 +132,7 @@ describe('apply', () => { sessions.list.update((draft) => { draft.ids.push(sid('kid')) draft.byId[sid('kid')] = { - id: sid('kid'), title: 'child', cwd: '/proj', parentId: sid('a'), running: false, updatedAt: 2, + id: sid('kid'), title: 'child', displayTitle: 'child', cwd: '/proj', parentId: sid('a'), running: false, updatedAt: 2, } }) await ctx.plugin({ inject: [...inject], apply }).await() diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index db03c672dd..077cdc9c56 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -31,6 +31,7 @@ function summary(init: SummaryInit): SessionSummary { const s: SessionSummary = { id: sid(init.id), title: init.title ?? init.id, + displayTitle: init.title ?? init.id, running: init.running ?? false, updatedAt: init.updatedAt ?? 0, } diff --git a/packages/client/ui-sidebar/tests/store.spec.ts b/packages/client/ui-sidebar/tests/store.spec.ts index d0bb3b386b..fbdb64c993 100644 --- a/packages/client/ui-sidebar/tests/store.spec.ts +++ b/packages/client/ui-sidebar/tests/store.spec.ts @@ -19,6 +19,7 @@ function summary(init: SummaryInit): SessionSummary { const s: SessionSummary = { id: sid(init.id), title: init.title ?? init.id, + displayTitle: init.title ?? init.id, running: init.running ?? false, updatedAt: init.updatedAt ?? 0, } diff --git a/packages/client/ui-sidebar/tests/tree.spec.ts b/packages/client/ui-sidebar/tests/tree.spec.ts index 1b29d460cf..ece5c769c6 100644 --- a/packages/client/ui-sidebar/tests/tree.spec.ts +++ b/packages/client/ui-sidebar/tests/tree.spec.ts @@ -11,6 +11,7 @@ const sid = (s: string) => s as SessionId interface SummaryInit { id: string title?: string + displayTitle?: string cwd?: string parentId?: string running?: boolean @@ -20,10 +21,11 @@ interface SummaryInit { function summary(init: SummaryInit): SessionSummary { const s: SessionSummary = { id: sid(init.id), - title: init.title ?? init.id, + displayTitle: init.displayTitle ?? init.title ?? init.id, running: init.running ?? false, updatedAt: init.updatedAt ?? 0, } + if (init.title !== undefined) s.title = init.title if (init.cwd !== undefined) s.cwd = init.cwd if (init.parentId !== undefined) s.parentId = sid(init.parentId) return s @@ -211,6 +213,15 @@ describe('deriveRows search', () => { const rows = deriveRows(list, view({ query: ' ' })) expect(rows.every(r => r.type === 'project')).toBe(true) }) + + it('matches the effective display title when no durable title is available', () => { + const fallback = listOf(summary({ id: 'raw-id', displayTitle: 'project fallback', cwd: '/elsewhere' })) + const rows = deriveRows(fallback, view({ query: 'fallback' })) + expect(rows).toEqual([ + expect.objectContaining({ type: 'project', key: '/elsewhere' }), + expect.objectContaining({ type: 'session', id: 'raw-id', title: 'project fallback' }), + ]) + }) }) describe('formatRelativeTime', () => { diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 41a3c0c95b..a020431698 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -52,7 +52,7 @@ async function bench() { function mount(svc: ConversationService, nodes: ConversationSnapshot['nodes'] = NODES) { const { useSession } = fakeSession(nodes) const activeStore = createSnapshotStore(undefined) - const ancestry: SessionSummary[] = [{ id: SID, title: 'self', running: false, updatedAt: 1 }] + const ancestry: SessionSummary[] = [{ id: SID, title: 'self', displayTitle: 'self', running: false, updatedAt: 1 }] const viewProps = { sessionId: SID, useSession, useSelection: () => null, diff --git a/packages/client/web/src/DocumentTitle.tsx b/packages/client/web/src/DocumentTitle.tsx new file mode 100644 index 0000000000..608f97497d --- /dev/null +++ b/packages/client/web/src/DocumentTitle.tsx @@ -0,0 +1,22 @@ +import { useEffect, useRef } from 'react' + +/** Props for the shell-owned browser title projection. */ +export interface DocumentTitleProps { + /** Durable title of the selected session, or undefined for the product title. */ + title?: string +} + +/** + * Project the selected durable session title into the browser title and + * restore the shell's original product title when unmounted. + * @param props - selected session title projection. + * @returns no rendered content. + */ +export function DocumentTitle({ title }: DocumentTitleProps): null { + const original = useRef(document.title) + useEffect(() => { + document.title = title === undefined ? original.current : `${title} — ${original.current}` + return () => { document.title = original.current } + }, [title]) + return null +} diff --git a/packages/client/web/src/app.tsx b/packages/client/web/src/app.tsx index e8c25acc3d..581cd4c98f 100644 --- a/packages/client/web/src/app.tsx +++ b/packages/client/web/src/app.tsx @@ -11,6 +11,7 @@ import { createSessionProvider, RootBindingProvider, scopedSlots, } from '@deepseek-ai/dsh-client-web-react' import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' +import { DocumentTitle } from './DocumentTitle.tsx' type LayoutExports = typeof import('@deepseek-ai/dsh-client-ui-layout/client') @@ -47,6 +48,11 @@ export function buildRenderApp(deps: AssemblyDeps): () => ReactNode { const useDetails = layout.details.useSelector const setSidebarWidth = (px: number): void => { layout.setSidebarWidth(px) } const setDetailsWidth = (px: number): void => { layout.setDetailsWidth(px) } + const SessionDocumentTitle = (): ReactNode => { + const id = useCurrent() + const title = sessions.list.useSelector(state => id === undefined ? undefined : state.byId[id]?.title) + return + } const renderBody = (id: SessionId): ReactNode => ( <> @@ -75,6 +81,7 @@ export function buildRenderApp(deps: AssemblyDeps): () => ReactNode { return () => ( + (id === 's1' ? binding : undefined), }) }, @@ -119,6 +119,7 @@ afterEach(() => { delete win.__TEST_NAV__ document.body.innerHTML = '' document.head.querySelectorAll('script').forEach((s) => { s.remove() }) + document.title = '' }) describe('bootWebShell (real loader + real script execution)', () => { @@ -130,6 +131,7 @@ describe('bootWebShell (real loader + real script execution)', () => { ], } const el = mountPoint() + document.title = 'DeepSeek Harness' let unmount: (() => void) | undefined const s = seams({ '/plugins/fake-runtime.js': RUNTIME_STUB, @@ -145,9 +147,11 @@ describe('bootWebShell (real loader + real script execution)', () => { // Selected session: SessionProvider resolved the binding and renderBody // mounted the conversation slot content into the center column. expect(el.querySelector('[data-testid="conv-body"]')).not.toBeNull() + expect(document.title).toBe('S1 — DeepSeek Harness') act(() => { unmount!() }) expect(el.childElementCount).toBe(0) + expect(document.title).toBe('DeepSeek Harness') }) it('no selected session: renderEmpty keeps the grid and forwards width setters', async () => { @@ -159,6 +163,7 @@ describe('bootWebShell (real loader + real script execution)', () => { ], } const el = mountPoint() + document.title = 'DeepSeek Harness' const s = seams({ '/plugins/fake-runtime.js': RUNTIME_STUB, '/plugins/fake-layout.js': LAYOUT_STUB.replace("id: 'fake-layout'", `id: '${LAYOUT_ID}'`), @@ -169,6 +174,7 @@ describe('bootWebShell (real loader + real script execution)', () => { expect(frame).not.toBeNull() // Empty path: no conversation body (nothing registered into conversation.empty → fallback null). expect(el.querySelector('[data-testid="conv-body"]')).toBeNull() + expect(document.title).toBe('DeepSeek Harness') // Width setter/selector pass-through (assembly closures over ctx.layout). expect((frame as HTMLElement).dataset['widths']).toBe('300x360') act(() => { (frame as HTMLElement).click() }) diff --git a/packages/client/web/tests/document-title.spec.tsx b/packages/client/web/tests/document-title.spec.tsx new file mode 100644 index 0000000000..ed336a1ccc --- /dev/null +++ b/packages/client/web/tests/document-title.spec.tsx @@ -0,0 +1,28 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, render } from '@testing-library/react' +import { DocumentTitle } from '../src/DocumentTitle.tsx' + +afterEach(() => { + cleanup() + document.title = '' +}) + +describe('DocumentTitle', () => { + it('preserves the product title without a durable title and restores it on unmount', () => { + document.title = 'DeepSeek Harness' + const mounted = render() + expect(document.title).toBe('DeepSeek Harness') + + mounted.rerender() + expect(document.title).toBe('First title — DeepSeek Harness') + + mounted.rerender() + expect(document.title).toBe('Revised title — DeepSeek Harness') + + mounted.rerender() + expect(document.title).toBe('DeepSeek Harness') + mounted.unmount() + expect(document.title).toBe('DeepSeek Harness') + }) +}) diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index a46cdfe09e..050063d5e9 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -25,6 +25,7 @@ export const askUserQuestionItemSchema = z.object({ export const muxFrameSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('session/event'), sessionId: sessionIdSchema, event: sessionEventSchema, view: toolEventViewSchema.optional() }), z.object({ type: z.literal('session/subscribed'), sessionId: sessionIdSchema, lastSeq: z.number().int() }), + z.object({ type: z.literal('session/title'), sessionId: sessionIdSchema, title: z.string().min(1), eventSeq: z.number().int().nonnegative(), updatedAt: z.number() }), z.object({ type: z.literal('approval/requested'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, toolName: z.string(), callId: z.string().optional(), reason: z.string().optional() }), z.object({ type: z.literal('approval/resolved'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, outcome: z.union([z.literal('allowed-once'), z.literal('rejected'), z.literal('cancelled'), z.literal('unavailable')]) }), z.object({ type: z.literal('question/requested'), sessionId: sessionIdSchema, questions: z.array(askUserQuestionItemSchema) }), diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index eac4f0e65c..c03877c31d 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -33,8 +33,9 @@ export type ToolEventView = export interface EventsApi { /** * All-session aggregated mux stream. On open, emits a subscribed control frame for every - * attached session and replays each session's still-pending approval/question requested - * frames (rpcId reused verbatim — the refresh-recovery baseline). + * attached session followed by its optional latest title snapshot, then replays each + * session's still-pending approval/question requested frames (rpcId reused verbatim — the + * refresh-recovery baseline). * since: resume seam, unimplemented in v1 (ignored if passed); reconnection = reopen the * stream + refetch history. */ @@ -54,6 +55,7 @@ export interface EventsApi { export type MuxFrame = | { type: 'session/event'; sessionId: SessionId; event: SessionEvent; view?: ToolEventView } | { type: 'session/subscribed'; sessionId: SessionId; lastSeq: number } + | { type: 'session/title'; sessionId: SessionId; title: string; eventSeq: number; updatedAt: number } | { type: 'approval/requested'; sessionId: SessionId; approvalId: ApprovalRequestId; toolName: string; callId?: CallId; reason?: string } | { type: 'approval/resolved'; sessionId: SessionId; approvalId: ApprovalRequestId; outcome: ApprovalOutcome } | { type: 'question/requested'; sessionId: SessionId; questions: AskUserQuestionItem[] } diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 764c9673b9..23cda690ec 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -123,6 +123,7 @@ describe('events frame schemas', () => { const frames = [ { type: 'session/event', sessionId: 's', event: { type: 't', seq: 0, time: 1, data: null } }, { type: 'session/subscribed', sessionId: 's', lastSeq: -1 }, + { type: 'session/title', sessionId: 's', title: 'Durable title', eventSeq: 2, updatedAt: 3 }, { type: 'approval/requested', sessionId: 's', approvalId: 'a', toolName: 'bash', callId: 'c', reason: 'r' }, { type: 'approval/resolved', sessionId: 's', approvalId: 'a', outcome: 'allowed-once' }, { type: 'question/requested', sessionId: 's', questions: [{ id: 'q', question: 'Q?', options: [{ label: 'L' }], multiSelect: true }] }, @@ -131,6 +132,13 @@ describe('events frame schemas', () => { ] for (const frame of frames) expect(muxFrameSchema.parse(frame)).toMatchObject({ type: frame.type }) expect(() => muxFrameSchema.parse({ type: 'unknown/frame' })).toThrow() + for (const invalid of [ + { type: 'session/title', sessionId: 's', title: '', eventSeq: 0, updatedAt: 1 }, + { type: 'session/title', sessionId: 's', title: 'x', eventSeq: -1, updatedAt: 1 }, + { type: 'session/title', sessionId: 's', title: 'x', eventSeq: 0.5, updatedAt: 1 }, + { type: 'session/title', sessionId: 's', title: 'x', eventSeq: 0, updatedAt: 'now' }, + { type: 'session/title', sessionId: 's', title: 'x', eventSeq: 0, updatedAt: Number.NaN }, + ]) expect(() => muxFrameSchema.parse(invalid)).toThrow() expect(askUserQuestionItemSchema.parse({ id: 'q', question: 'Q?' }).id).toBe('q') }) diff --git a/packages/host/runtime/package.json b/packages/host/runtime/package.json index ea32040012..62e19f6635 100644 --- a/packages/host/runtime/package.json +++ b/packages/host/runtime/package.json @@ -49,6 +49,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-spill-local": "workspace:^", diff --git a/packages/host/runtime/src/api-proxy.ts b/packages/host/runtime/src/api-proxy.ts index 0621fbecb9..901e74dcf5 100644 --- a/packages/host/runtime/src/api-proxy.ts +++ b/packages/host/runtime/src/api-proxy.ts @@ -12,6 +12,7 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' +import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' import type { ApiProxy, HistoryEntry, HostFrame, MuxFrame, SessionSummary, ToolEventView } from '@deepseek-ai/dsh-host-apiproxy/api' import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' @@ -104,6 +105,28 @@ function frame(payload: F): RpcRequest { return { rpcId: RpcId(randomUUID()), payload } } +type SessionTitleFrame = Extract + +/** Project the latest durable title without exposing title-generation policy. */ +function titleFrame(session: Session): SessionTitleFrame | undefined { + const title = foldSessionTitle(session.events) + if (title === undefined) return undefined + return { + type: 'session/title', + sessionId: session.id, + title: title.title, + eventSeq: title.eventSeq, + updatedAt: title.updatedAt, + } +} + +/** Queue the subscription baseline followed by its optional title snapshot. */ +function subscribeSession(queue: FrameQueue>, session: Session): void { + queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 })) + const title = titleFrame(session) + if (title !== undefined) queue.push(frame(title)) +} + /** SessionSummary projection for attached (in-memory) sessions. */ function summarize(session: Session, running: boolean): SessionSummary { return { @@ -362,7 +385,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro mux(_request, signal) { const queue = new FrameQueue>() for (const session of ctx.sessions.list()) { - queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 })) + subscribeSession(queue, session) } // Per-session open-call table for result-view pairing. Bounded by the // per-turn call count: entries clear on turn/end; a table miss (stream @@ -385,9 +408,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const view = viewFor(ctx, event, callId => openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId)) queue.push(frame({ type: 'session/event', sessionId: session.id, event, ...view === undefined ? {} : { view } })) + if (event.type === 'session/title') { + // The accepted raw event is already in session.events, so the fold must find it. + queue.push(frame(titleFrame(session) as SessionTitleFrame)) + } }), ctx.on('session/created', (session: Session) => { - queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 })) + subscribeSession(queue, session) }), ctx.on('session/disposed', (session: Session) => { openCalls.delete(session.id) diff --git a/packages/host/runtime/tests/host-runtime.spec.ts b/packages/host/runtime/tests/host-runtime.spec.ts index f3a2dc9a82..81bc4fc009 100644 --- a/packages/host/runtime/tests/host-runtime.spec.ts +++ b/packages/host/runtime/tests/host-runtime.spec.ts @@ -65,6 +65,21 @@ function expectOk(response: RpcResponse): T { return response.result.value } +async function nextMux(iterator: AsyncIterator>): Promise> { + const next = await iterator.next() + if (next.done === true) throw new Error('mux ended before the expected frame') + return next.value +} + +/** Durably append a title event without mounting title-generation policy. */ +function appendTitle(ctx: Context, agent: Agent, title: string) { + return ctx.sessions.appendOutOfBand(agent.session, 'session/title', { + title, + messageSeqs: [1], + source: { kind: 'fallback' }, + }, { kind: 'session-title' }) +} + let host: RunningHost | undefined beforeEach(() => { @@ -203,11 +218,14 @@ describe('sessions.history', () => { const idle = waitForIdle(first.ctx, agent) agent.send([{ type: 'text', text: 'save me' }]) await idle + const titleEvent = await appendTitle(first.ctx, agent, 'Persisted title') await first.dispose() host = await startHost({ boot: { persistenceRoot, provider: 'scripted', model: 'test-model' } }) host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([])) expect(host.ctx.agents.get(sessionId)).toBeUndefined() + const abort = new AbortController() + const mux = host.api.events.mux(request({}), abort.signal)[Symbol.asyncIterator]() const [a, b] = await Promise.all([ host.api.sessions.history(request({ sessionId })), host.api.sessions.history(request({ sessionId })), @@ -218,6 +236,11 @@ describe('sessions.history', () => { } expect(host.ctx.agents.get(sessionId)).toBeDefined() expect(host.ctx.agents.list()).toHaveLength(1) + expect((await nextMux(mux)).payload).toMatchObject({ type: 'session/subscribed', sessionId }) + expect((await nextMux(mux)).payload).toEqual(expect.objectContaining({ + type: 'session/title', sessionId, title: 'Persisted title', eventSeq: titleEvent.seq, + })) + abort.abort() }) it('errors session-not-found when resume fails, deduplicating concurrent resumes', async () => { @@ -325,6 +348,43 @@ describe('events streams', () => { expect((await stream.next()).done).toBe(true) }) + it('mux: projects durable titles after open baselines and immediately after live raw events', async () => { + const running = await boot() + const { api, ctx } = running + const { sessionId } = expectOk(await api.sessions.create(request({}))) + const agent = ctx.agents.get(sessionId) as Agent + const initial = await appendTitle(ctx, agent, 'Initial title') + + const ac = new AbortController() + const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]() + expect((await nextMux(stream)).payload).toMatchObject({ type: 'session/subscribed', sessionId }) + expect((await nextMux(stream)).payload).toEqual(expect.objectContaining({ + type: 'session/title', sessionId, title: 'Initial title', eventSeq: initial.seq, updatedAt: initial.time, + })) + + const revised = await appendTitle(ctx, agent, 'Revised title') + let raw: RpcRequest + do raw = await nextMux(stream) + while (!(raw.payload.type === 'session/event' && raw.payload.event.type === 'session/title')) + expect(raw.payload).toMatchObject({ type: 'session/event', sessionId, event: { seq: revised.seq } }) + expect((await nextMux(stream)).payload).toEqual(expect.objectContaining({ + type: 'session/title', sessionId, title: 'Revised title', eventSeq: revised.seq, updatedAt: revised.time, + })) + ac.abort() + }) + + it('mux: emits no title control for untitled subscriptions', async () => { + const { api } = await boot() + const first = expectOk(await api.sessions.create(request({}))).sessionId + const ac = new AbortController() + const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]() + expect((await nextMux(stream)).payload).toMatchObject({ type: 'session/subscribed', sessionId: first }) + + const second = expectOk(await api.sessions.create(request({}))).sessionId + expect((await nextMux(stream)).payload).toMatchObject({ type: 'session/subscribed', sessionId: second }) + ac.abort() + }) + it('host: session lifecycle, status flips (disposed suppressed), and agent errors', async () => { const running = await boot([textResponse('x')]) const { api, ctx } = running diff --git a/packages/host/runtime/tsconfig.json b/packages/host/runtime/tsconfig.json index 9836e0b5fd..72789891fb 100644 --- a/packages/host/runtime/tsconfig.json +++ b/packages/host/runtime/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../core/session" }, + { + "path": "../../session-title/session-title" + }, { "path": "../../core/system-prompt" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 968891f28d..28ee8bfebd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2032,6 +2032,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-session-title': + specifier: workspace:^ + version: link:../../session-title/session-title '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../../skill/skill diff --git a/vitest.snapshot.config.ts b/vitest.snapshot.config.ts index 142528e604..858a3fd9a1 100644 --- a/vitest.snapshot.config.ts +++ b/vitest.snapshot.config.ts @@ -41,6 +41,7 @@ export default defineConfig({ test: { setupFiles: ['./scripts/test-invariants.ts'], include: [ + 'apps/web/tests/**/*.snapshot.ts', 'examples/*/tests/**/*.snapshot.ts', 'packages/sdk/*/tests/**/*.snapshot.ts', 'packages/ui/tui/tests/**/*.snapshot.ts', From 2e91db9271f953c6dde8f0b18e31f0cce5eb79ad Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:44:16 +0800 Subject: [PATCH 04/39] docs(web): describe session title projection --- ...026-07-21-log-backed-session-titles.i18n.yaml | 4 ++-- .../2026-07-21-log-backed-session-titles.md | 3 ++- .../2026-07-21-log-backed-session-titles.zh.md | 3 ++- .../2026-07-20-gui-testing-system.i18n.yaml | 4 ++-- .../process/2026-07-20-gui-testing-system.md | 16 ++++++++-------- .../process/2026-07-20-gui-testing-system.zh.md | 16 ++++++++-------- packages/client/runtime/README.md | 5 ++++- packages/client/web/README.md | 2 ++ packages/host/apiproxy/README.md | 2 ++ 9 files changed, 32 insertions(+), 23 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml index 32b0b3a218..17f4515c1d 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.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 -2026-07-21-log-backed-session-titles.md: cd0d2a4bab9b6504c65e942c0e03bce79488364e -2026-07-21-log-backed-session-titles.zh.md: b90ac6c59677e6542733210b91de38ef1169c760 +2026-07-21-log-backed-session-titles.md: 494187a73c58fb2313d802825c3ec9f9994d6f2b +2026-07-21-log-backed-session-titles.zh.md: cae51cca920fad748cb1944d35cc1b80850eb6ee diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md index cd0d2a4bab..494187a73c 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md @@ -40,7 +40,7 @@ Automatic provider failures are nonfatal warnings and retain the latest title. E A fork inherits seed title events unchanged, like the rest of its source log. The first-message provider does not automatically retitle a fork. The all-messages provider may append a child-owned revision after a later child prompt, using inherited and new eligible messages. -`ctx.sessionQuery.readTitle()` folds one live-preferred or persisted log without loading titles during `listSessions()`. ACP maps the event to `session_info_update` during both live streaming and load replay, using the event timestamp for `updatedAt`. The TUI uses the latest title as its header subtitle and sets the terminal window title to `` after terminal-safe rendering. A synthetic title turn remains a completed durability boundary for the metadata write; consumers reporting agent completion use the core `findLastMessageTurnEnd()` fold so a later title, injection, or other plugin-owned turn cannot replace the preceding message-triggered outcome. +`ctx.sessionQuery.readTitle()` folds one live-preferred or persisted log without loading titles during `listSessions()`. ACP maps the event to `session_info_update` during both live streaming and load replay, using the event timestamp for `updatedAt`. The TUI uses the latest title as its header subtitle and sets the terminal window title to `` after terminal-safe rendering. The Web host folds the same log state into a validated mux control frame after each attached-session subscription baseline and immediately after forwarding a live raw title event. The browser retains only newer title event seqs even when the control frame precedes list or session-instance creation; sidebar labels, search, breadcrumbs, and the browser title then react to the projected revision. `session.list` remains metadata-only, so a cold persisted session uses the cwd basename or id until opening or resuming it attaches the log. The browser title uses `` only for a selected titled session and otherwise preserves the product title. A synthetic title turn remains a completed durability boundary for the metadata write; consumers reporting agent completion use the core `findLastMessageTurnEnd()` fold so a later title, injection, or other plugin-owned turn cannot replace the preceding message-triggered outcome. ## Alternatives considered @@ -54,6 +54,7 @@ A fork inherits seed title events unchanged, like the rest of its source log. Th ## Consequences - Titles survive JSONL and SQLite persistence, replay through ACP, and follow fork inheritance without a separate mutable record. +- Web title delivery stays incremental and log-backed without a title index or persisted-list scan; cold list rows improve after attach. - A fallback appears without an auxiliary call; deployments choose whether better titles justify model cost and whether later prompts should retitle a session. - Auxiliary request records and late accepted titles consume event seqs and may create balanced zero-step turns, so persistence exposes both attempted dispatches and accepted updates even though model history and KV-cache identity do not change. - One provider and monotonic per-session revisions make disposal, supersession, and stale-result rejection explicit, at the cost of leaving multi-strategy precedence to a composite provider. diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md index b90ac6c596..cae51cca92 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md @@ -40,7 +40,7 @@ Status: implemented 与源日志的其他部分相同,fork 会原样继承作为种子的标题事件。首消息提供方不会自动为 fork 重新生成标题。全部消息提供方可以在子会话出现后续提示词后追加一项归子会话所有的修订,并使用继承的合格消息和新增的合格消息。 -`ctx.sessionQuery.readTitle()` 会折叠一份实时优先或已持久化的日志,而不会在 `listSessions()` 期间加载标题。ACP(Agent Client Protocol)会在实时流式输出和加载回放期间把该事件映射到 `session_info_update`,并使用事件时间戳作为 `updatedAt`。TUI 使用最新标题作为其标题栏副标题,并在完成终端安全渲染后,将终端窗口标题设置为 ``。合成标题轮次本身仍会完成,并作为元数据写入的持久性边界;报告 agent 完成情况的消费方使用核心的 `findLastMessageTurnEnd()` 折叠逻辑,因此后续的标题轮次、注入轮次或其他归插件所有的轮次无法取代此前由消息触发的结果。 +`ctx.sessionQuery.readTitle()` 会折叠一份实时优先或已持久化的日志,而不会在 `listSessions()` 期间加载标题。ACP(Agent Client Protocol)会在实时流式输出和加载回放期间把该事件映射到 `session_info_update`,并使用事件时间戳作为 `updatedAt`。TUI 使用最新标题作为其标题栏副标题,并在完成终端安全渲染后,将终端窗口标题设置为 ``。Web host 会在每个已附加会话的订阅基线之后,以及转发实时原始标题事件后立即,将同一份日志状态折叠为经过校验的 mux 控制帧。即使控制帧先于列表或会话实例创建抵达,浏览器也只保留标题事件 seq 较新的版本;侧边栏标签、搜索、面包屑和浏览器标题会随投影后的修订更新。`session.list` 仍只包含元数据,因此尚未打开的持久化会话会继续以 cwd 基名或 id 作为回退,直至打开或恢复会话时附加其日志。浏览器仅在选中已有标题的会话时将标题设置为 ``,否则保留产品标题。合成标题轮次本身仍会完成,并作为元数据写入的持久性边界;报告 agent 完成情况的消费方使用核心的 `findLastMessageTurnEnd()` 折叠逻辑,因此后续的标题轮次、注入轮次或其他归插件所有的轮次无法取代此前由消息触发的结果。 ## 考虑过的替代方案 @@ -54,6 +54,7 @@ Status: implemented ## 后果 - 标题可以在 JSONL 和 SQLite 持久化中存续,通过 ACP 回放,并遵循 fork 继承语义,而无需单独的可变记录。 +- Web 标题仍以增量方式从日志交付,无需标题索引或扫描持久化列表;冷启动列表项会在会话附加后改用标题。 - 回退标题无需辅助调用即可出现;部署方可以自行决定更优标题是否值得模型成本,以及后续提示词是否需要重新生成会话标题。 - 辅助请求记录和延迟接受的标题会占用事件 seq,并可能创建平衡的零步骤轮次,因此持久化会同时呈现尝试发起的调用与已接受的更新,尽管模型历史和 KV 缓存标识保持不变。 - 单个提供方和每会话单调递增的修订号让释放、取代和陈旧结果拒绝行为明确可见,但多策略优先级必须由复合提供方负责。 diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml index 6b2f1de1e0..9c7fff8b8b 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.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 -2026-07-20-gui-testing-system.md: db1b47566f5aa089ffcb10d130ecde1851b93112 -2026-07-20-gui-testing-system.zh.md: 691c6baf50c1025a09461effd28ac0f1650fb933 +2026-07-20-gui-testing-system.md: 28652f97d5c8d4968e2beef0ccffa7a39dcd7359 +2026-07-20-gui-testing-system.zh.md: e07d23ded9613251b71808d56e05365120d9c0a7 diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md index db1b47566f..28652f97d5 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md @@ -19,24 +19,24 @@ Cut along the architecture's natural test seams into three tiers, bottom-up: | Tier | Under test | Key technique | File location | |---|---|---|---| | 1 Protocol isomorphism | `AbstractApiClient` + `toFetchHandler` (bidirectional data / rpcId / zod types / SSE streams / batching / timeouts) | **The full chain at the isomorphic point**: `InProcessApiClient(toFetchHandler(脚本化 impl))` skips the network but genuinely runs the wire serialization — zero browser, pure node env | `packages/host/apiproxy/tests/client-handler.spec.ts` | -| 2 Object-layer orchestration | `Session`/`SessionManager`/`ConnectionController` (state machines and timing: stitching / dedup / paging / optimistic draft clearing / pendingBuffers / reconnect / backoff) | **The "event sequence in → snapshot out" golden path**: programmable fakes + deferreds controlling timing + fake timers controlling backoff | `packages/client/web-runtime/tests/{session,manager,connection,…}.spec.ts` | -| 3 Browser smoke | Build artifacts × a real browser (the page boots, one conversation round-trips) | Bare playwright library (chromium headless, no @playwright/test framework), minimal pass-through; fixture level + real-host level (self-skips without a key) | `apps/web/tests/smoke-{fixture,real}.e2e.ts` | +| 2 Object-layer orchestration | `Session`/`SessionManager`/`ConnectionController` (state machines and timing: stitching / dedup / paging / optimistic draft clearing / pendingBuffers / reconnect / backoff) | **The "event sequence in → snapshot out" golden path**: programmable fakes + deferreds controlling timing + fake timers controlling backoff | `packages/client/{runtime,connection}/tests/` | +| 3 Assembled presentation | Built artifacts × the real client loader and plugin composition | App-owned semantic snapshots boot all eight built client plugins under jsdom for deterministic cross-plugin state changes; bare Playwright smoke separately proves the real browser/carrier boundary, with real-host cases self-skipping without a key | `apps/web/tests/*.snapshot.ts`, `apps/web/tests/smoke-{fixture,real}.e2e.ts` | -Inter-tier discipline: **each tier tests its own layer, upper tiers never re-test lower ones** — smoke only proves the wiring is alive (the fixture level asserts zero `/api` requests and zero pageerror), interaction detail belongs to the verify scripts (see the lane map), wire semantics to tier 1, data semantics to tier 2. Pure-function layers (lineage/partial/notifier/fold-adapter) are tested directly with zero fakes in the same package's tests/ alongside tier 2. +Inter-tier discipline: **each tier tests its own layer, upper tiers never re-test lower ones** — an app semantic snapshot pins only user-visible projection across the assembled plugin boundary, while Playwright smoke proves browser and carrier liveness; wire semantics belong to tier 1 and data semantics to tier 2. Pure-function layers (lineage/partial/notifier/fold-adapter) are tested directly with zero fakes in the same package's tests/ alongside tier 2. -- **Host side** (apiproxy/runtime/webserver): under the repo-wide `test:coverage` gate, per-file 100%. -- **Client side**: web-runtime **is already under the per-file 100% gate** (12 defensive unreachable arms carry reasoned `/* v8 ignore */` comments); the `vitest.config.ts` coverage.exclude is down to `packages/client/web-ui/src/**` (temporary — lifted progressively as component specs fill in after the component redo); tests still run, the exclusion only keeps web-ui src out of the thresholds. web-ui takes the **jsdom route (landed)**: jsdom + @testing-library/react entered root devDependencies (dev-only), first spec `web-ui/tests/utils.spec.tsx` (utils pure functions + component RTL render + hook uSES probe); the environment uses the per-file `// @vitest-environment jsdom` pragma, zero impact on the other node-env packages. -- The exclusion is an **explicitly annotated ruling**, not a silent waiver; the lift path = delete the exclude line + add a justified exclusion or the missing tests. +- **Host and client source** are under the repo-wide per-file 100% coverage gate except the narrow browser-grade exclusions annotated in `vitest.config.ts`; component suites use per-file jsdom pragmas and Testing Library without changing Node suites. +- **App-owned semantic snapshots** read built client bundles, execute them through the real loader, and drive only deterministic fixture hooks. They own stable visible state such as sidebar labels, breadcrumbs, and `document.title`, not CSS pixels or lower-layer state-machine details. ## Lane map | Scenario | Command | Content | When to run | |---|---|---|---| | Baseline | `pnpm run test:gui` | Tier 1+2 vitest (`packages/client packages/host`), seconds-fast, no browser, no server | Casually, after touching any GUI source | +| Semantic snapshot | `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot` | Keyless assembled-application semantics plus the repo's transport-specific expected outputs | After a human-visible GUI change; before delivery | | Browser end-to-end | `pnpm run test:web` | Rebuilds the front-end dist first, then runs the tier-3 two-level smoke (fixture level + real-host level self-skip) | After touching the build surface/boot/carriage; before delivery | | Gate | `pnpm run test:coverage` | The repo-wide gate (host-side GUI packages included, client side excluded) | The PR window | -**Division of labor between the verify scripts and vitest**: verify owns browser black-box regression (sequential steps = a user-operation script, one shared browser session, streaming PASS/FAIL output for the agent to locate the break), vitest owns first-class data-layer semantic assertions (reference stability `toBe`, state-machine timing, wire shapes). The two lanes complement each other, neither absorbs the other — scripts do not migrate to vitest (tearing apart an ordered script is a net loss); promoting one means wrapping a spawn shell hooked into the e2e lane, never rewriting the script body. +**Division of labor between the browser scripts and vitest**: Playwright owns browser/carrier black-box regression and long sequential user journeys; ordinary vitest owns data-layer semantics such as reference stability, timing, and wire shapes; snapshot vitest owns stable app-level semantic output through the built composition. These lanes complement each other rather than duplicating assertions. ## Anti-regression discipline @@ -46,7 +46,7 @@ Inter-tier discipline: **each tier tests its own layer, upper tiers never re-tes ## Consequences -Each lane tests its own tier: touching any GUI source gets seconds-fast `test:gui` feedback, wire/object-layer semantics assert in milliseconds in node env, and the browser carries only wiring-liveness smoke. On the gate surface, the host side is fully under per-file 100%; on the client side web-runtime is under the gate while web-ui waits behind the explicitly annotated exclude. The accepted cost: the inter-tier discipline (upper tiers never re-test lower ones) is upheld by review rather than a machine gate, and web-ui's coverage gap persists until component specs fill in after the component redo. +Each lane tests its own tier: touching any GUI source gets seconds-fast `test:gui` feedback, wire/object-layer semantics assert in milliseconds in Node, built-composition snapshots pin deterministic user-visible projection, and the browser carries wiring and carrier acceptance. The accepted cost is that inter-tier discipline is upheld by review rather than a machine gate and every new app snapshot must avoid unstable layout or clock output. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md index 691c6baf50..e07d23ded9 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md @@ -19,24 +19,24 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境 | 层 | 被测物 | 关键手段 | 文件落点 | |---|---|---|---| | 1 协议同构层 | `AbstractApiClient` + `toFetchHandler`(双向数据/rpcId/ZOD类型/SSE 流/合批/超时) | **同构点全链**:`InProcessApiClient(toFetchHandler(脚本化 impl))` 不过网络但真跑 wire 序列化——零浏览器、纯 node env | `packages/host/apiproxy/tests/client-handler.spec.ts` | -| 2 对象层编排 | `Session`/`SessionManager`/`ConnectionController`(状态机与时序:缝合/去重/翻页/乐观清稿/pendingBuffers/重连/退避) | **「事件序列进→快照出」黄金路径**:可编程假体 + deferred 控时序 + fake timers 控退避 | `packages/client/web-runtime/tests/{session,manager,connection,…}.spec.ts` | -| 3 浏览器 smoke | 构建产物 × 真浏览器(页面起得来、一轮对话跑得通) | playwright 裸库(chromium headless,无 @playwright/test 框架)最简跑通;fixture 级 + 真 host 级(无 key self-skip) | `apps/web/tests/smoke-{fixture,real}.e2e.ts` | +| 2 对象层编排 | `Session`/`SessionManager`/`ConnectionController`(状态机与时序:缝合/去重/翻页/乐观清稿/pendingBuffers/重连/退避) | **「事件序列进→快照出」黄金路径**:可编程假体 + deferred 控时序 + fake timers 控退避 | `packages/client/{runtime,connection}/tests/` | +| 3 组装呈现层 | 构建产物 × 真实 client loader 与插件组合 | 归应用所有的语义快照会在 jsdom 下启动全部 8 个已构建的 client 插件,以固定确定性的跨插件状态变化;独立使用 Playwright 裸库的冒烟测试负责验证真实浏览器/承载层边界,真 host 用例在无密钥时自行跳过 | `apps/web/tests/*.snapshot.ts`、`apps/web/tests/smoke-{fixture,real}.e2e.ts` | -层间纪律:**下层各测各的,上层不重测下层**——smoke 只证接线活着(fixture 级断零 `/api` 请求、零 pageerror),交互细节归 verify 脚本(见车道地图),wire 语义归 1 层,数据语义归 2 层。纯函数层(lineage/partial/notifier/fold-adapter)随 2 层同包 tests/ 零假体直测。 +层间纪律:**下层各测各的,上层不重测下层**:应用语义快照只固定组装后插件边界上的用户可见投影,Playwright 冒烟测试负责验证浏览器与承载层是否存活;wire 语义归 1 层,数据语义归 2 层。纯函数层(lineage/partial/notifier/fold-adapter)随 2 层同包 tests/ 零假体直测。 -- **host 侧**(apiproxy/runtime/webserver):进全仓 `test:coverage` 门禁,per-file 100%。 -- **client 侧**:web-runtime **已进 per-file 100% 门禁**(12 处防御性不可达臂带理由 `/* v8 ignore */` 注释);`vitest.config.ts` coverage.exclude 只剩 `packages/client/web-ui/src/**`(暂时——组件重做后随组件 specs 铺满逐步解除),测试照跑,只是不拉 web-ui src 进阈值。web-ui 走 **jsdom 路线(已落地)**:jsdom + @testing-library/react 入 root devDeps(dev-only),首个 spec `web-ui/tests/utils.spec.tsx`(utils 纯函数 + 组件 RTL render + hook uSES 探针);环境用 per-file `// @vitest-environment jsdom` pragma,node env 的其他包零影响。 -- 排除是**显式注释的裁决**不是静默豁免;解除路径=删 exclude 行 + 补 justified 排除或补测。 +- **host 与 client 源码**均纳入全仓 per-file 100% 覆盖率门禁,仅排除 `vitest.config.ts` 中带注释的少量浏览器级例外;组件套件通过逐文件 jsdom pragma 和 Testing Library 运行,不会改变 Node 套件。 +- **归应用所有的语义快照**读取已构建的 client bundle,通过真实 loader 执行它们,并且只驱动确定性的 fixture 钩子。它们负责固定侧边栏标签、面包屑和 `document.title` 等稳定可见状态,而不固定 CSS 像素或下层状态机细节。 ## 车道地图 | 场景 | 命令 | 内容 | 何时跑 | |---|---|---|---| | 基础 | `pnpm run test:gui` | 1+2 层 vitest(`packages/client packages/host`),秒级、无浏览器无 server | 改 GUI 任意源码后随手跑 | +| 语义快照 | `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot` | 无需密钥的组装应用语义,以及仓库按传输形态划分的预期输出 | 用户可见的 GUI 变更后;交付前 | | 浏览器端到端 | `pnpm run test:web` | 先重建前端 dist,再跑 3 层双级 smoke(fixture 级 + 真 host 级 self-skip) | 改构建面/boot/承载后;交付前 | | 门禁 | `pnpm run test:coverage` | 全仓 gate(host 侧 GUI 包在内,client 侧 excluded) | PR 窗口 | -**verify 脚本与 vitest 的分工**:verify 管浏览器黑盒回归(顺序步骤=用户操作剧本,共享一次浏览器会话,PASS/FAIL 流式输出供 agent 定位断点),vitest 管数据层语义一等断言(引用稳定性 `toBe`、状态机时序、wire 形)。两车道互补不收编——脚本不迁 vitest(拆散有序剧本是负收益),转正时包一层 spawn 壳挂 e2e 车道即可,脚本本体不改写。 +**浏览器脚本与 vitest 的分工**:Playwright 负责浏览器/承载层黑盒回归和较长的连续用户操作流程;普通 vitest 负责引用稳定性、时序和 wire 结构等数据层语义;快照 vitest 通过构建后的组合负责稳定的应用层语义输出。这些车道彼此互补,而不重复断言。 ## 防回归纪律 @@ -46,7 +46,7 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境 ## Consequences -各车道各测各层:改任意 GUI 源码有秒级 `test:gui` 反馈,wire/对象层语义在 node env 毫秒级断言,浏览器只承担接线存活冒烟。门禁面上 host 侧全量进 per-file 100%;client 侧 web-runtime 已进门,web-ui 暂留显式注释的 exclude 之后。接受的代价:层间纪律(上层不重测下层)靠 review 而非机器门禁维持;web-ui 的覆盖缺口持续到组件重做后组件 specs 铺满为止。 +各车道各测各层:改动任意 GUI 源码后都能获得秒级 `test:gui` 反馈,wire/对象层语义在 Node 环境中进行毫秒级断言,基于构建后组合的快照固定确定性的用户可见投影,浏览器负责接线与承载层验收。接受的代价是层间纪律由评审而非机器门禁维持,而且每个新的应用快照都必须避开不稳定的布局或时钟输出。 ## Alternatives considered diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 01c4172902..7c200ad202 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -2,6 +2,10 @@ Client cordis boot + core services: SlotsService (Service wrapper over SlotCore + 'slots/changed' bridge), SessionsService (list store projection, scope tree, bindings, ancestry), Session object layer, ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4. +## Session title projection + +`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and explicit session removal clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title. + ## Model Experience None, as the client runtime hosts browser-side services and the session object layer; nothing here reaches a model request. @@ -15,4 +19,3 @@ None; this package neither assembles nor sends a provider request. - **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project. - **Scope teardown is watch-approximated** — the most recently resolved binding stands in for "who is watching"; a removed-while-watched session's scope survives until the watch moves away, not until true observer count reaches zero. - **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem). -- **`SessionSummary.title` is a display projection** — the wire summary carries no title yet; the cwd basename stands in, then the raw id. diff --git a/packages/client/web/README.md b/packages/client/web/README.md index 0501cb0384..c405aca3bc 100644 --- a/packages/client/web/README.md +++ b/packages/client/web/README.md @@ -4,6 +4,8 @@ Web shell library: `bootWebShell(el, seams?)` mounts the whole client — loader The optional `seams` parameter forwards the client loader's `fetchBundle`/`executeBundle` transport overrides (`BootSeams`); production callers omit it — it exists for test environments where `` const head = html.indexOf('') if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}` @@ -141,7 +157,12 @@ export function injectBootManifest(html: string, plugins: readonly unknown[]): s return `${script}${html}` } -/** Serve one plugin client bundle from the registry table (unknown id = 404; the id may contain a scope slash). */ +/** + * Serve one plugin client bundle from the registry table (unknown id = 404; + * the id may contain a scope slash). The `?rev=` query is a cache-busting + * parameter only — serving ignores it; `no-cache` makes the browser revalidate + * so a stale rev never sticks. + */ async function servePluginBundle( pathname: string, res: ServerResponse, webPlugins: Pick, ): Promise { @@ -154,7 +175,7 @@ async function servePluginBundle( } try { const body = await readFile(path) - res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' }) + res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' }) res.end(body) } catch { // Registered but unreadable (bundle not built yet): loud 404 beats a silent SPA-fallback HTML page. diff --git a/packages/host/webserver/src/invariant.ts b/packages/host/webserver/src/invariant.ts index 87e3463121..a204c93775 100644 --- a/packages/host/webserver/src/invariant.ts +++ b/packages/host/webserver/src/invariant.ts @@ -15,25 +15,27 @@ export const name = 'host-webserver-invariant' export const inject = ['invariants'] /** - * Owned relation: the web plugin registry's boot manifest must stay - * self-consistent — every snapshot() row must resolve a clientPath under the - * same id (the /plugins//client.js URL it advertises would otherwise 404 - * on a browser that just received the manifest). Checked synchronously on - * every rescan trigger (cordis 'internal/plugin'): snapshot() and - * clientPath() read the same table object, so the relation is - * self-consistent at any instant — no need to wait out the registry's own - * debounced rescan. The registry arrives through the context key the - * assembly publishes it under. + * Owned relation: the web plugin registry's boot entry graph must stay + * self-consistent — every row must resolve a clientPath under the same id + * (the /plugins//client.js URL it advertises would otherwise 404 on a + * browser that just received the graph). Checked synchronously on every + * rescan trigger (cordis 'internal/plugin'): graph() and clientPath() read + * the same table object, so the relation is self-consistent at any instant — + * no need to wait out the registry's own debounced rescan. The registry + * arrives through the context key the assembly publishes it under. */ const install: InvariantInstaller = (ctx, fail) => { ctx.on('internal/plugin', () => { const registry = ctx.get('webPlugins') as - | { snapshot(): { id: string; url: string }[]; clientPath(id: string): string | undefined } + | { + graph(): { entries: { id: string; url: string }[] } + clientPath(id: string): string | undefined + } | undefined if (registry === undefined) return // carrier-only deployments never publish the registry - for (const row of registry.snapshot()) { + for (const row of registry.graph().entries) { if (registry.clientPath(row.id) === undefined) { - fail(`web plugin manifest row "${row.id}" advertises ${row.url} but resolves no client bundle path — the served __DSH_BOOT__ would 404 on fetch`) + fail(`web plugin graph row "${row.id}" advertises ${row.url} but resolves no client bundle path — the served __DSH_BOOT__ would 404 on fetch`) } } }, { global: true }) diff --git a/packages/host/webserver/src/plugin-events.ts b/packages/host/webserver/src/plugin-events.ts new file mode 100644 index 0000000000..b438edf948 --- /dev/null +++ b/packages/host/webserver/src/plugin-events.ts @@ -0,0 +1,56 @@ +/** + * `/plugins/events` SSE channel: the system-side push surface for the client + * entry graph (connect → current graph frame; dev rebuild → rebuilt frame). + * Presentation-only wire — frames never enter the session log (distinct from + * the /api/* session SSE, which is api-contract territory). Connections are + * plain node:http responses held in a set; the server's closeAllConnections + * tears them down on shutdown. + */ + +import type { ServerResponse } from 'node:http' +import type { WebBootGraph } from './web-plugins.ts' + +/** One `/plugins/events` frame: the full graph on connect, or one rebuilt bundle notice. */ +export type PluginEventFrame = + | { type: 'graph'; graph: WebBootGraph } + | { type: 'rebuilt'; id: string; rev: string } + +/** Broadcast surface owned by the webserver routing layer. */ +export interface PluginEventChannel { + /** Adopt one incoming SSE request: writes the SSE preamble and the current-graph frame, then keeps the response open. */ + connect(res: ServerResponse, graph: WebBootGraph): void + /** Push one frame to every open connection. */ + broadcast(frame: PluginEventFrame): void +} + +/** Serialize one frame as an SSE data line. */ +function sseData(frame: PluginEventFrame): string { + return `data: ${JSON.stringify(frame)}\n\n` +} + +/** + * Create the channel (one per running server). + * @returns the connect/broadcast surface. + */ +export function createPluginEventChannel(): PluginEventChannel { + const connections = new Set() + return { + connect(res, graph) { + res.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + 'connection': 'keep-alive', + }) + // Comment line on open so clients/proxies see a live channel even when + // no rebuild ever happens; EventSource frame parsing skips it naturally. + res.write(': connected\n\n') + res.write(sseData({ type: 'graph', graph })) + connections.add(res) + res.on('close', () => { connections.delete(res) }) + }, + broadcast(frame) { + const line = sseData(frame) + for (const res of connections) res.write(line) + }, + } +} diff --git a/packages/host/webserver/src/web-plugins.ts b/packages/host/webserver/src/web-plugins.ts index ee1e72f34c..7998576f4e 100644 --- a/packages/host/webserver/src/web-plugins.ts +++ b/packages/host/webserver/src/web-plugins.ts @@ -1,10 +1,17 @@ /** - * HostWebPluginRegistry: discovers web-client plugins among the host Loader's - * loaded entries by their package.json `dshClient` declaration and resolves - * each one's client bundle path from `exports["./client"]`. The webserver - * consumes the table to emit `window.__DSH_BOOT__` and to serve - * `GET /plugins//client.js`. Discovery is declaration-only: plugin authors - * write package.json; no serve() call surface exists. + * HostWebPluginRegistry: composes the client entry graph served as + * `window.__DSH_BOOT__` ({rev, entries}). Every row is discovered among the + * host Loader's loaded entries by its package.json `dshClient` declaration + * (all client plugin packages arrive by fetch — one uniform bundle shape), + * resolving each one's client bundle path from `exports["./client"]` and + * hashing the bundle content into a `rev` (cache busting + HMR diff anchor). + * `inject` edges and the `immediately` prefetch mark come from the manifest + * (dshClient — the package owns its dependency edges and its boot tier); the + * composition layer contributes only the roster. The webserver consumes the + * table to emit the boot graph and to serve `GET /plugins//client.js`; + * in dev mode the registry additionally stat-polls each scanned bundle file + * and re-hashes + notifies `onRebuilt` subscribers on change (the rebuild + * signal is the registry's own observation — no builder protocol exists). * * The vendored loader emits no "entry loaded" event (only `loader/entry-init`, * which fires at Entry construction before import/apply), so the registry @@ -14,33 +21,59 @@ * fresh within a process lifetime. */ -import { readFileSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { readFileSync, unwatchFile, watchFile } from 'node:fs' +import type { Stats } from 'node:fs' import { dirname, join } from 'node:path' import type { Context } from 'cordis' -/** One `window.__DSH_BOOT__.plugins` row (wire shape of api-contracts v3 §9.2). */ -export interface WebPluginBootEntry { - /** Plugin id = package name (may contain a scope slash). */ +/** One composed client entry (`window.__DSH_BOOT__.entries` row). */ +export interface WebBootEntry { + /** Entry name == package name. */ id: string - /** Bundle URL served by this webserver (`/plugins//client.js`). */ + /** Bundle URL served by this webserver (`/plugins//client.js?rev=`). */ url: string - /** Client-half load dependencies (plugin ids), topologically ordered by the client loader. */ - inject: string[] - /** Marks the early-load group: fetched in parallel and applied before all other plugins. */ + /** Bundle content hash (sha1, shortened). */ + rev: string + /** Package-name dependency edges from the manifest (dshClient.inject), informational (preflight/HMR display). */ + inject?: string[] + /** Boot phase-one prefetch tier: the shell fetches these bundles in parallel before creating entries. */ immediately?: boolean } -/** The web plugin table consumed by the boot injection and the bundle endpoint. */ +/** The composed entry graph: injected into index.html and pushed on /plugins/events connect. */ +export interface WebBootGraph { + /** Consistency anchor over all rows: changes whenever any entry row changes. */ + rev: string + /** All composed entries (order carries no semantics; governance ordering is the client Loader's job). */ + entries: WebBootEntry[] +} + +/** The web plugin table consumed by the boot injection, the bundle endpoint, and the rebuild channel. */ export interface HostWebPluginRegistry { - /** Current manifest rows (stable order: loader entry order). */ - snapshot(): WebPluginBootEntry[] + /** Current composed entry graph (stable object between changes). */ + graph(): WebBootGraph /** - * Absolute path of a plugin's client bundle. - * @param id - plugin id (package name). + * Absolute path of an entry's client bundle. + * @param id - entry id (package name). * @returns the path, or undefined for an unknown id. */ clientPath(id: string): string | undefined - /** Remove the loader subscription. */ + /** + * Re-hash one entry's bundle: updates the row's rev/url and the graph rev. + * The dev bundle watch calls this on every observed file change. + * @param id - entry id (package name). + * @returns the new bundle rev, or undefined for an unknown id. + */ + rebuilt(id: string): string | undefined + /** + * Subscribe to bundle rebuilds observed by the dev watch (only fires when + * the re-hash produced a different rev — an unchanged bundle is silent). + * @param listener - receives the entry id and its new bundle rev. + * @returns the unsubscriber. + */ + onRebuilt(listener: (id: string, rev: string) => void): () => void + /** Remove the loader subscription, all bundle watches, and all rebuild listeners. */ dispose(): void } @@ -72,17 +105,28 @@ export interface WebPluginRegistryDeps { resolvePkgJson: (name: string) => string /** Sink for rescan failures (the initial scan throws instead — misconfiguration fails loud at load). */ onError: (err: Error) => void + /** + * Dev-mode bundle watching: stat-poll every scanned row's client bundle + * (fs.watchFile — polling by design: network mounts deliver no inotify + * events) and re-hash + notify onRebuilt subscribers on change. Absent = + * no watching (prod composition). + */ + watch?: { + /** Stat-poll interval in milliseconds; default 500 (the build-side watcher's polling default). */ + intervalMs?: number + } } /** package.json `dshClient` declaration shape (file boundary — validated field by field). */ interface DshClientDeclaration { inject?: string[] platform: string + /** Boot phase-one prefetch mark; absent means lazy (fetched on demand). */ immediately?: boolean } interface WebPluginRecord { - entry: WebPluginBootEntry + entry: WebBootEntry clientPath: string } @@ -122,15 +166,94 @@ function clientExportOf(name: string, exportsField: unknown): string | undefined throw new Error(`web-plugins: ${name} exports["./client"] has an unsupported shape`) } +/** sha1 content hash shortened to 12 hex chars (bundle rev / graph rev). */ +function shortHash(input: string | Buffer): string { + return createHash('sha1').update(input).digest('hex').slice(0, 12) +} + +/** Graph row for one bundle rev (url carries the rev as its cache-busting query). */ +function graphRow(id: string, rev: string, inject: string[] | undefined, immediately: boolean): WebBootEntry { + return { + id, + url: `/plugins/${id}/client.js?rev=${rev}`, + rev, + ...(inject !== undefined ? { inject } : {}), + ...(immediately ? { immediately: true } : {}), + } +} + +/** Compose the graph value from the current table. */ +function composeGraph(table: Map): WebBootGraph { + const entries = [...table.values()].map(record => record.entry) + return { rev: shortHash(JSON.stringify(entries)), entries } +} + /** * Build the web plugin registry: scan once synchronously (a malformed - * declaration throws here — load-time fail loud), then rescan on - * `internal/plugin`, microtask-debounced (failures go to `deps.onError`). - * @param deps - loader view, resolution hook, and error sink (see {@link WebPluginRegistryDeps}). + * declaration, an unbuilt bundle, or an invalid watch interval throws here — + * load-time fail loud), then rescan on `internal/plugin`, microtask-debounced + * (failures go to `deps.onError`). With `deps.watch`, every scanned bundle + * file is stat-polled and a content change re-hashes the row and notifies + * `onRebuilt` subscribers. + * @param deps - loader view, resolution hook, error sink, and optional dev watch (see {@link WebPluginRegistryDeps}). * @returns the registry handle. */ export function createHostWebPluginRegistry(deps: WebPluginRegistryDeps): HostWebPluginRegistry { + const watchInterval = deps.watch === undefined ? undefined : deps.watch.intervalMs ?? 500 + if (watchInterval !== undefined && (!Number.isInteger(watchInterval) || watchInterval <= 0)) { + throw new Error(`web-plugins: watch.intervalMs must be a positive integer (got ${String(deps.watch?.intervalMs)})`) + } + let table = scan(deps) + let graph = composeGraph(table) + const rebuildListeners = new Set<(id: string, rev: string) => void>() + + const rebuilt = (id: string): string | undefined => { + const record = table.get(id) + if (record === undefined) return undefined + const rev = shortHash(readFileSync(record.clientPath)) + record.entry = graphRow(id, rev, record.entry.inject, record.entry.immediately === true) + graph = composeGraph(table) + return rev + } + + // Dev bundle watch: one fs.watchFile stat poll per table row. A torn read + // of a half-written bundle self-heals — the ongoing write keeps changing + // the stats, so the next poll tick re-hashes the completed file. + const watched = new Map void }>() + const syncWatches = (): void => { + if (watchInterval === undefined) return + for (const [id, watch] of watched) { + if (table.get(id)?.clientPath === watch.path) continue + unwatchFile(watch.path, watch.listener) + watched.delete(id) + } + for (const [id, record] of table) { + if (watched.has(id)) continue + const listener = (curr: Stats, prev: Stats): void => { + // fs.watchFile fires on any stat delta (atime included); only content + // signals count. An all-zero curr means the file vanished mid-rebuild + // — the completing write fires the next tick, so skipping is safe. + if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) return + if (curr.mtimeMs === 0) return + const before = table.get(id)?.entry.rev + let rev: string | undefined + try { + rev = rebuilt(id) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'ENOENT') return // mid-rename window; the completed write fires the next poll tick + deps.onError(error instanceof Error ? error : new Error(String(error))) + return + } + if (rev === undefined || rev === before) return + for (const notify of rebuildListeners) notify(id, rev) + } + watchFile(record.clientPath, { interval: watchInterval, persistent: false }, listener) + watched.set(id, { path: record.clientPath, listener }) + } + } + syncWatches() let pending = false const unsubscribe = deps.ctx.on('internal/plugin', () => { @@ -140,8 +263,10 @@ export function createHostWebPluginRegistry(deps: WebPluginRegistryDeps): HostWe pending = false try { table = scan(deps) + graph = composeGraph(table) + syncWatches() } catch (error) { - // Keep serving the previous table: a mid-flight rescan failure must not + // Keep serving the previous graph: a mid-flight rescan failure must not // take down the boot manifest for plugins that were fine. deps.onError(error instanceof Error ? error : new Error(String(error))) } @@ -149,13 +274,23 @@ export function createHostWebPluginRegistry(deps: WebPluginRegistryDeps): HostWe }) return { - snapshot: () => [...table.values()].map(record => record.entry), + graph: () => graph, clientPath: id => table.get(id)?.clientPath, - dispose: () => { unsubscribe() }, + rebuilt, + onRebuilt: (listener) => { + rebuildListeners.add(listener) + return () => { rebuildListeners.delete(listener) } + }, + dispose: () => { + unsubscribe() + for (const { path, listener } of watched.values()) unwatchFile(path, listener) + watched.clear() + rebuildListeners.clear() + }, } } -/** One full table build from the loader's current entries. */ +/** One full table build from the loader's current entries (bundle content is hashed here — an unreadable bundle throws). */ function scan(deps: WebPluginRegistryDeps): Map { const table = new Map() for (const entry of deps.loader.entries()) { @@ -170,15 +305,9 @@ function scan(deps: WebPluginRegistryDeps): Map { if (clientRel === undefined) { throw new Error(`web-plugins: ${name} declares dshClient but exports no "./client" bundle`) } - table.set(name, { - entry: { - id: name, - url: `/plugins/${name}/client.js`, - inject: decl.inject ?? [], - ...(decl.immediately === true ? { immediately: true } : {}), - }, - clientPath: join(dirname(pkgPath), clientRel), - }) + const clientPath = join(dirname(pkgPath), clientRel) + const rev = shortHash(readFileSync(clientPath)) + table.set(name, { entry: graphRow(name, rev, decl.inject, decl.immediately === true), clientPath }) } return table } diff --git a/packages/host/webserver/tests/invariant.spec.ts b/packages/host/webserver/tests/invariant.spec.ts index 8fddba992a..f9d5ba4490 100644 --- a/packages/host/webserver/tests/invariant.spec.ts +++ b/packages/host/webserver/tests/invariant.spec.ts @@ -1,7 +1,7 @@ /** - * Webserver invariant companion: the boot-manifest consistency audit — every - * registry snapshot row must resolve a clientPath, checked on fiber lifecycle - * events against the assembly-published 'webPlugins' context key. + * Webserver invariant companion: the boot-graph consistency audit — every + * fetch-arrival graph row must resolve a clientPath, checked on fiber + * lifecycle events against the assembly-published 'webPlugins' context key. */ import { Context } from 'cordis' import { describe, expect, it } from 'vitest' @@ -9,7 +9,7 @@ import InvariantService from '@deepseek-ai/dsh-invariants' import * as WebserverInvariant from '../src/invariant.ts' interface RegistryStub { - snapshot(): { id: string; url: string }[] + graph(): { entries: { id: string; url: string }[] } clientPath(id: string): string | undefined } @@ -33,18 +33,18 @@ describe('webserver manifest invariant', () => { expect(() => { trigger(bare) }).not.toThrow() // no 'webPlugins' key published const consistent = await setup({ - snapshot: () => [{ id: 'p1', url: '/plugins/p1/client.js' }], - clientPath: () => '/tmp/p1/lib/client.js', + graph: () => ({ entries: [{ id: 'p1', url: '/plugins/p1/client.js?rev=abc' }] }), + clientPath: id => id === 'p1' ? '/tmp/p1/lib/client.js' : undefined, }) expect(() => { trigger(consistent) }).not.toThrow() }) - it('throws on a manifest row whose bundle path no longer resolves', async () => { + it('throws on a graph row whose bundle path no longer resolves', async () => { const ctx = await setup({ - snapshot: () => [{ id: 'ghost', url: '/plugins/ghost/client.js' }], + graph: () => ({ entries: [{ id: 'ghost', url: '/plugins/ghost/client.js?rev=abc' }] }), clientPath: () => undefined, }) expect(() => { trigger(ctx) }) - .toThrow(/manifest row "ghost".*resolves no client bundle path/) + .toThrow(/graph row "ghost".*resolves no client bundle path/) }) }) diff --git a/packages/host/webserver/tests/web-plugins.spec.ts b/packages/host/webserver/tests/web-plugins.spec.ts index bb08f2ac11..b9efb1c5c9 100644 --- a/packages/host/webserver/tests/web-plugins.spec.ts +++ b/packages/host/webserver/tests/web-plugins.spec.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from 'cordis' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { createHostWebPluginRegistry, injectBootManifest } from '../src/index.ts' import type { LoaderEntryView, WebPluginRegistryDeps } from '../src/index.ts' @@ -25,6 +25,7 @@ interface Fixture { entries: LoaderEntryView[] errors: Error[] ctx: Context + root: string } function makeDeps( @@ -48,32 +49,30 @@ function makeDeps( }, onError: err => void errors.push(err), } - return { deps, entries, errors, ctx } + return { deps, entries, errors, ctx, root } } describe('createHostWebPluginRegistry', () => { - it('collects loaded web-declared plugins with url/inject/immediately and client paths', () => { + it('discovers dshClient rows with rev-stamped urls, manifest inject edges, and the declared immediately mark', () => { const { deps } = makeDeps([ { name: '@deepseek-ai/dsh-client-connection', pkg: webDecl({ immediately: true }) }, { name: '@deepseek-ai/dsh-client-ui-layout', pkg: webDecl({ inject: ['@deepseek-ai/dsh-client-runtime'] }) }, { name: '@deepseek-ai/dsh-agent', pkg: { exports: { '.': './lib/index.js' } } }, // no dshClient: skipped ]) const registry = createHostWebPluginRegistry(deps) - const rows = registry.snapshot() - expect(rows).toEqual([ - { - id: '@deepseek-ai/dsh-client-connection', - url: '/plugins/@deepseek-ai/dsh-client-connection/client.js', - inject: [], - immediately: true, - }, - { - id: '@deepseek-ai/dsh-client-ui-layout', - url: '/plugins/@deepseek-ai/dsh-client-ui-layout/client.js', - inject: ['@deepseek-ai/dsh-client-runtime'], - }, - ]) - expect(registry.clientPath('@deepseek-ai/dsh-client-connection')).toMatch(/lib[/\\]client\.js$/) + const graph = registry.graph() + expect(graph.rev).toMatch(/^[0-9a-f]{12}$/) + const connection = graph.entries[0] + expect(connection?.id).toBe('@deepseek-ai/dsh-client-connection') + expect(connection?.rev).toMatch(/^[0-9a-f]{12}$/) + expect(connection?.url).toBe(`/plugins/@deepseek-ai/dsh-client-connection/client.js?rev=${connection?.rev ?? ''}`) + expect(connection?.immediately).toBe(true) + const layout = graph.entries[1] + expect(layout?.id).toBe('@deepseek-ai/dsh-client-ui-layout') + expect(layout?.inject).toEqual(['@deepseek-ai/dsh-client-runtime']) + expect(layout?.immediately).toBeUndefined() + expect(graph.entries).toHaveLength(2) + expect(registry.clientPath('@deepseek-ai/dsh-client-ui-layout')).toMatch(/lib[/\\]client\.js$/) expect(registry.clientPath('@deepseek-ai/dsh-agent')).toBeUndefined() registry.dispose() }) @@ -85,7 +84,7 @@ describe('createHostWebPluginRegistry', () => { { name: 'electron-only', pkg: { dshClient: { platform: 'electron' }, exports: { './client': './lib/client.js' } } }, ]) const registry = createHostWebPluginRegistry(deps) - expect(registry.snapshot()).toEqual([]) + expect(registry.graph().entries).toEqual([]) registry.dispose() }) @@ -96,6 +95,11 @@ describe('createHostWebPluginRegistry', () => { expect(() => createHostWebPluginRegistry(deps)).toThrow(/declares dshClient but exports no/) }) + it('fails loud at build time on a registered bundle that is not built (rev hashing reads the file)', () => { + const { deps } = makeDeps([{ name: 'unbuilt', pkg: webDecl(), withBundle: false }]) + expect(() => createHostWebPluginRegistry(deps)).toThrow(/ENOENT/) + }) + it('fails loud on malformed declaration fields', () => { for (const dshClient of [42, { platform: 7 }, { platform: 'web', inject: 'nope' }, { platform: 'web', immediately: 'yes' }]) { const { deps } = makeDeps([{ name: 'bad', pkg: { dshClient, exports: { './client': './lib/client.js' } } }]) @@ -103,26 +107,74 @@ describe('createHostWebPluginRegistry', () => { } }) - it('rescans on internal/plugin (debounced) and keeps the old table when a rescan fails', async () => { + it('rebuilt(id) re-hashes the bundle, updates the row and graph rev, and keeps the immediately mark', () => { + const { deps, root } = makeDeps([{ name: 'hot', pkg: webDecl({ immediately: true }) }]) + const registry = createHostWebPluginRegistry(deps) + const before = registry.graph() + const beforeRow = before.entries.find(e => e.id === 'hot') + writeFileSync(join(root, 'hot', 'lib', 'client.js'), '// rebuilt bundle contents') + const rev = registry.rebuilt('hot') + expect(rev).toMatch(/^[0-9a-f]{12}$/) + expect(rev).not.toBe(beforeRow?.rev) + const after = registry.graph() + const afterRow = after.entries.find(e => e.id === 'hot') + expect(afterRow?.rev).toBe(rev) + expect(afterRow?.url).toBe(`/plugins/hot/client.js?rev=${rev ?? ''}`) + expect(afterRow?.immediately).toBe(true) + expect(after.rev).not.toBe(before.rev) + // Unknown ids are not rebuildable. + expect(registry.rebuilt('nope')).toBeUndefined() + registry.dispose() + }) + + it('watch mode: a bundle content change re-hashes the row and notifies onRebuilt; dispose stops the watch', async () => { + const { deps, root } = makeDeps([{ name: 'watched', pkg: webDecl() }]) + deps.watch = { intervalMs: 20 } + const registry = createHostWebPluginRegistry(deps) + const before = registry.graph().entries[0]?.rev + const rebuilds: { id: string; rev: string }[] = [] + registry.onRebuilt((id, rev) => rebuilds.push({ id, rev })) + + writeFileSync(join(root, 'watched', 'lib', 'client.js'), '// new bundle contents') + await vi.waitFor(() => { expect(rebuilds).toHaveLength(1) }, { timeout: 5000 }) + expect(rebuilds[0]?.id).toBe('watched') + expect(rebuilds[0]?.rev).not.toBe(before) + expect(registry.graph().entries[0]?.rev).toBe(rebuilds[0]?.rev) + + registry.dispose() + writeFileSync(join(root, 'watched', 'lib', 'client.js'), '// post-dispose contents') + await new Promise((resolve) => { setTimeout(resolve, 100) }) + expect(rebuilds).toHaveLength(1) + }) + + it('rejects a non-positive or non-integer watch interval at build time', () => { + for (const intervalMs of [0, -5, 1.5]) { + const { deps } = makeDeps([{ name: 'p', pkg: webDecl() }]) + deps.watch = { intervalMs } + expect(() => createHostWebPluginRegistry(deps)).toThrow(/watch\.intervalMs/) + } + }) + + it('rescans on internal/plugin (debounced) and keeps the old graph when a rescan fails', async () => { const { deps, entries, errors, ctx } = makeDeps([ { name: 'late-loader', pkg: webDecl(), loaded: false }, ]) const registry = createHostWebPluginRegistry(deps) - expect(registry.snapshot()).toEqual([]) + expect(registry.graph().entries).toEqual([]) // Entry finishes loading; a fiber lifecycle event triggers the debounced rescan. ;(entries[0] as { fiber?: unknown }).fiber = {} ctx.emit('internal/plugin', ctx.fiber) ctx.emit('internal/plugin', ctx.fiber) // debounce: two emissions, one rescan await Promise.resolve() - expect(registry.snapshot().map(row => row.id)).toEqual(['late-loader']) + expect(registry.graph().entries.map(row => row.id)).toEqual(['late-loader']) - // A failing rescan reports the error and keeps serving the previous table. + // A failing rescan reports the error and keeps serving the previous graph. entries.push({ options: { name: 'ghost' }, fiber: {}, disabled: false }) ctx.emit('internal/plugin', ctx.fiber) await Promise.resolve() expect(errors).toHaveLength(1) - expect(registry.snapshot().map(row => row.id)).toEqual(['late-loader']) + expect(registry.graph().entries.map(row => row.id)).toEqual(['late-loader']) // After dispose, further fiber events no longer rescan. registry.dispose() @@ -134,16 +186,19 @@ describe('createHostWebPluginRegistry', () => { }) describe('injectBootManifest', () => { - it('injects the manifest as the first script inside and escapes breakouts', () => { + it('injects the graph as the first script inside and escapes breakouts', () => { const html = '' - const out = injectBootManifest(html, [{ id: 'x