From a777000512d2947e3c28e6f86ee7501acd3e248d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 00:21:47 +0800 Subject: [PATCH 01/52] fix(user-interaction): preserve multi-select custom answers --- ...select-custom-answer-composition.i18n.yaml | 6 + ...-multi-select-custom-answer-composition.md | 25 ++++ ...lti-select-custom-answer-composition.zh.md | 25 ++++ .../user-interaction.i18n.yaml | 6 +- docs/core-data-structures/user-interaction.md | 4 +- .../user-interaction.zh.md | 4 +- .../tests/fixtures/tui-scripted-llm.ts | 7 ++ .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 9 +- packages/client/ui-question/README.i18n.yaml | 4 +- packages/client/ui-question/README.md | 2 +- packages/client/ui-question/README.zh.md | 2 +- .../src/client/QuestionComposer.tsx | 24 ++-- .../tests/question-composer.spec.tsx | 11 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 + packages/host/apiproxy/README.zh.md | 2 + packages/host/apiproxy/src/api-proxy.ts | 2 +- .../apiproxy/tests/api-proxy-question.spec.ts | 116 ++++++++++++++++++ packages/ui/tool-ask-user/README.i18n.yaml | 6 +- packages/ui/tool-ask-user/README.md | 2 +- packages/ui/tool-ask-user/README.zh.md | 2 +- .../tool-ask-user/tests/tool-ask-user.spec.ts | 6 +- packages/ui/tui/README.i18n.yaml | 4 +- packages/ui/tui/README.md | 2 +- packages/ui/tui/README.zh.md | 2 +- packages/ui/tui/src/components/dialogs.ts | 20 ++- packages/ui/tui/tests/tui.spec.ts | 6 +- packages/ui/user-interaction/README.i18n.yaml | 6 +- packages/ui/user-interaction/README.md | 2 +- packages/ui/user-interaction/README.zh.md | 2 +- packages/ui/user-interaction/src/types.ts | 2 +- 31 files changed, 269 insertions(+), 48 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.zh.md create mode 100644 packages/host/apiproxy/tests/api-proxy-question.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.i18n.yaml new file mode 100644 index 0000000000..bb081e4be8 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.md +2026-07-30-multi-select-custom-answer-composition.md: 7194f4a79f1dd49eba4a9b626d75203fced06544 +2026-07-30-multi-select-custom-answer-composition.zh.md: fac09c8db0ebf2dd4a84ade7aa7868128656025d diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.md b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.md new file mode 100644 index 0000000000..7194f4a79f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.md @@ -0,0 +1,25 @@ +# Agent Note: Multi-select custom answer composition + +Status: implemented + +English | [中文](2026-07-30-multi-select-custom-answer-composition.zh.md) + +## Problem + +The user-interaction result vocabulary carries selected option labels and optional custom text in separate fields, but its original semantics made them mutually exclusive for every question. On a multi-select question, opening or typing the custom answer discarded labels the user had already selected. The TUI returned only the custom text, and the Web host rejected a client response that preserved both fields. + +## Decision + +For a question with `multiSelect: true`, one answer item may contain both a non-empty `selected` array and non-empty `custom` text. Web drafts preserve both values regardless of whether the user selects an option or types custom text first; the TUI projects its checked option set when custom text is submitted; and the Web host accepts the combined response after applying its existing id, label, uniqueness, batch, and non-empty-text validation. + +Single-select and optionless questions keep exclusive semantics: custom text overrides any selected option. The result shape remains `{ id, selected, custom? }`, so no wire or tool-output schema changes. + +## Alternatives considered + +**Encode custom text as another `selected` label.** Rejected because it would erase the distinction between caller-provided option labels and human-authored text, weakening validation and forcing consumers to infer which value was custom. + +**Allow `selected` and `custom` together for every question.** Rejected because a single-select question represents one answer; permitting a selected option plus custom text would make its cardinality ambiguous. The combined form is limited to questions that explicitly opt into multiple answers. + +## Consequences + +Multi-select UIs can represent the user's complete answer without discarding either source. Providers and consumers retain the existing DTO, while request-aware validators interpret the allowed combination from `multiSelect`. Web, TUI, host-response, tool-projection, and assembled keyless TUI coverage pin the combined result; single-select host coverage pins the remaining exclusivity rule. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.zh.md new file mode 100644 index 0000000000..fac09c8db0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 多选题自定义答案组合 + +Status: implemented + +[English](2026-07-30-multi-select-custom-answer-composition.md) | 中文 + +## 问题 + +用户交互结果的词汇分别通过不同字段携带选中的选项标签和可选的自定义文本,但最初的语义要求每个问题的这两个字段互斥。对于多选题,打开自定义答案或输入文本会丢弃用户已选中的标签。TUI 只返回自定义文本,而 Web 宿主会拒绝同时保留两个字段的客户端响应。 + +## 决策 + +对于 `multiSelect: true` 的问题,一个回答项可以同时包含非空 `selected` 数组与非空 `custom` 文本。无论用户先选择选项还是先输入自定义文本,Web 草稿都会保留两个值;提交自定义文本时,TUI 会投影其已勾选的选项集合;Web 宿主则在应用现有的 id、标签、唯一性、批次和非空文本校验后接受组合响应。 + +单选题和无选项问题仍保持互斥语义:自定义文本会覆盖任何已选中的选项。结果形状仍为 `{ id, selected, custom? }`,因此协议或工具输出 schema 均无需变更。 + +## 考虑过的替代方案 + +**把自定义文本编码为另一个 `selected` 标签。** 不予采纳,因为这样会抹去调用方提供的选项标签与用户填写文本之间的区别,削弱校验,并迫使消费方推断哪个值属于自定义内容。 + +**允许所有问题同时使用 `selected` 与 `custom`。** 不予采纳,因为单选题只表示一个回答;允许选中选项与自定义文本并存会使其基数含义模糊。组合形式仅适用于显式选择多项回答的问题。 + +## 后果 + +多选 UI 可以完整表达用户的回答,不会丢弃任一来源。提供方和消费方继续使用现有 DTO,而请求感知的校验器会根据 `multiSelect` 判断是否允许组合。Web、TUI、宿主响应、工具投影和组装后的无密钥 TUI 覆盖会固定组合结果;单选题的宿主覆盖则固定其余的互斥规则。 diff --git a/docs/core-data-structures/user-interaction.i18n.yaml b/docs/core-data-structures/user-interaction.i18n.yaml index 66cb12815e..f764e9ca23 100644 --- a/docs/core-data-structures/user-interaction.i18n.yaml +++ b/docs/core-data-structures/user-interaction.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -user-interaction.md: 798a9790f424683775284a98421be08e6e1399e3 -user-interaction.zh.md: 12bfcffe4fe4caaacb54e90126eac55e333d64a5 +# pnpm run verify-translation-pairing --write docs/core-data-structures/user-interaction.md +user-interaction.md: db6ac5010ada9d02319bf148566792659711d2e4 +user-interaction.zh.md: a8306b421a03563ba9ae2ee48d04898d00eb668e diff --git a/docs/core-data-structures/user-interaction.md b/docs/core-data-structures/user-interaction.md index 798a9790f4..db6ac5010a 100644 --- a/docs/core-data-structures/user-interaction.md +++ b/docs/core-data-structures/user-interaction.md @@ -60,14 +60,14 @@ interface AskUserQuestionRequest { ## Answer -Providers return one answer item per question id. `selected` contains selected option labels, and `custom` carries a free-form "Other" answer when the user typed one. When `custom` is present, `selected` is empty; custom text is an answer override, not a supplement to selected choices. A UI may also use an item with empty `selected` and no `custom` to preserve a skipped question in an otherwise completed batch. +Providers return one answer item per question id. `selected` contains selected option labels, and `custom` carries a free-form "Other" answer when the user typed one. For a single-select question, `custom` overrides the selected choice and `selected` is empty. For a multi-select question, `custom` may supplement the labels in `selected`. A UI may also use an item with empty `selected` and no `custom` to preserve a skipped question in an otherwise completed batch. ```ts type-equiv /** Answer to one question. */ interface AskUserQuestionAnswerItem { /** The answered question id. */ id: string - /** Selected option labels. Empty for custom or unanswered choices. */ + /** Selected option labels. May accompany custom text for a multi-select question. */ selected: string[] /** Optional free-text "Other" answer. */ custom?: string diff --git a/docs/core-data-structures/user-interaction.zh.md b/docs/core-data-structures/user-interaction.zh.md index 12bfcffe4f..a8306b421a 100644 --- a/docs/core-data-structures/user-interaction.zh.md +++ b/docs/core-data-structures/user-interaction.zh.md @@ -60,14 +60,14 @@ interface AskUserQuestionRequest { ## 回答 -提供方为每个问题 id 返回一个回答项。`selected` 包含选中的选项标签,`custom` 在用户输入自由文本时携带「其他」回答。当 `custom` 存在时,`selected` 为空;自定义文本是对选中项的覆盖,而非补充。UI 也可以使用 `selected` 为空且不含 `custom` 的回答项,在其余问题均已完成的批次中保留被跳过的问题。 +提供方为每个问题 id 返回一个回答项。`selected` 包含选中的选项标签,`custom` 在用户输入自由文本时携带「其他」回答。对于单选题,`custom` 会覆盖选中的选项,且 `selected` 为空。对于多选题,`custom` 可以补充 `selected` 中的标签。UI 也可以使用 `selected` 为空且不含 `custom` 的回答项,在其余问题均已完成的批次中保留被跳过的问题。 ```ts type-equiv /** Answer to one question. */ interface AskUserQuestionAnswerItem { /** The answered question id. */ id: string - /** Selected option labels. Empty for custom or unanswered choices. */ + /** Selected option labels. May accompany custom text for a multi-select question. */ selected: string[] /** Optional free-text "Other" answer. */ custom?: string diff --git a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts index 517fa6adab..121bde9278 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts +++ b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts @@ -110,6 +110,12 @@ class ScriptedTuiAdapter extends LlmAdapter { const hasToolResult = lastMessage?.content.some(block => block.type === 'tool-result') ?? false if (hasToolResult) { + const toolResultText = lastMessage?.content.flatMap(block => block.type === 'tool-result' + ? block.content.flatMap(content => content.type === 'text' ? [content.text] : []) + : []).join('\n') ?? '' + if (toolResultText !== '{"answers":[{"id":"mode","selected":["Safe"],"custom":"Release notes"}]}') { + throw new Error(`the scripted TUI request received an unexpected question answer: ${toolResultText}`) + } for (const chunk of textChunks(FINAL_TEXT)) yield chunk return } @@ -119,6 +125,7 @@ class ScriptedTuiAdapter extends LlmAdapter { id: 'mode', header: 'Execution mode', question: 'How should the scripted run proceed?', + multi_select: true, options: [ { label: 'Safe', description: 'Use the guarded path.' }, { label: 'Fast', description: 'Use the shorter path.' }, diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 7ae98167de..0201ccebe8 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -138,6 +138,7 @@ const SELECT_PRO_MODEL = [ { waitFor: 'scripted TUI ready.', send: '/model\r' }, { waitFor: 'Select model', send: '\x1b[B\x1b[Z\r' }, ] as const +const ANSWER_MULTI_WITH_CUSTOM = ' \tRelease notes\r' describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { it('boots pi-tui, sweeps the borderless banner in, enters plan mode, and restores the terminal', async () => { @@ -174,7 +175,10 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { // The question text first appears in the streamed tool-call card. Wait // for the dialog's input legend so Enter cannot arrive before it owns // terminal input when pre-dispatch policy yields. - { waitFor: 'Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt', send: '\r' }, + { + waitFor: 'Tab custom answer • ↑/↓ navigate • Space toggle • Enter submit • Esc interrupt', + send: ANSWER_MULTI_WITH_CUSTOM, + }, { waitFor: 'Decision received. Scripted TUI run complete.', send: '' }, // Session title: the first user message drives the first-message-llm // provider's tool-less title call; the scripted adapter answers it, the @@ -200,6 +204,7 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { expect(output).not.toContain('\u001B[999CMODEL_CURSOR') expect(output).not.toContain('\u009B31mMODEL_C1') expect(output).toContain('Safe') + expect(output).toContain('Release notes') expect(output).toContain('\u001B]0;scripted session title — DeepSeek Harness\u0007') expect(output).toContain('Session status') expect(output).toContain('Title') @@ -395,7 +400,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { actions: [ ...SELECT_PRO_MODEL, { waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: 'exercise the TUI\r' }, - { waitFor: 'How should the scripted run proceed?', send: '\r' }, + { waitFor: 'How should the scripted run proceed?', send: ANSWER_MULTI_WITH_CUSTOM }, { waitFor: 'Decision received. Scripted TUI run complete.', send: '/exit\r' }, ], inspect: async (cwd) => { context = await readLoggedRequestContext(cwd) }, diff --git a/packages/client/ui-question/README.i18n.yaml b/packages/client/ui-question/README.i18n.yaml index a58cd055a7..7657062501 100644 --- a/packages/client/ui-question/README.i18n.yaml +++ b/packages/client/ui-question/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-question/README.md -README.md: 3a3cd639fc2834685230aca7c8087583e0a48c71 -README.zh.md: 1330578577da7ed7d0890595f675fd272fd5ebc7 +README.md: c36f1474e175b52c7d35af6b479ab5bfeabcd9ff +README.zh.md: 8986dee718a98920a20757aafb1bd4b54ac8f782 diff --git a/packages/client/ui-question/README.md b/packages/client/ui-question/README.md index 3a3cd639fc..c36f1474e1 100644 --- a/packages/client/ui-question/README.md +++ b/packages/client/ui-question/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Web `ask_user_question` feature plugin. Its host half mounts `dsh-tool-ask-user` only when the Web feature is selected; its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot. -The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`. +The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`. Selection state is local to a component keyed by the request rpcId. A replay with the same id preserves a still-mounted draft, while `question/resolved` from the host removes the composer. The host remains authoritative: successful HTTP delivery does not remove pending state locally. diff --git a/packages/client/ui-question/README.zh.md b/packages/client/ui-question/README.zh.md index 1330578577..8986dee718 100644 --- a/packages/client/ui-question/README.zh.md +++ b/packages/client/ui-question/README.zh.md @@ -4,7 +4,7 @@ Web `ask_user_question` 功能插件。只有选择 Web 功能时,其主机侧才会挂载 `dsh-tool-ask-user`;浏览器侧会把 `question` 配置项注册到会话拥有的 `conversation.composer` 键控 slot 中。 -组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信内容策略。封顶卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。单选选项会立即前进;所有问题均已回答或跳过后,Enter 会提交;IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。 +组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信内容策略。封顶卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。单选选项会立即前进;所有问题均已回答或跳过后,Enter 会提交;IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。 选择状态只存在于以请求 rpcId 为 key 的组件本地。使用相同 id 回放时,只要组件仍挂载,就会保留草稿;主机发出的 `question/resolved` 则会移除编辑器。主机仍具有最终决定权:HTTP 交付成功不会在本地移除待处理状态。 diff --git a/packages/client/ui-question/src/client/QuestionComposer.tsx b/packages/client/ui-question/src/client/QuestionComposer.tsx index 542a24b935..ebf2caf22f 100644 --- a/packages/client/ui-question/src/client/QuestionComposer.tsx +++ b/packages/client/ui-question/src/client/QuestionComposer.tsx @@ -86,12 +86,13 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) { const choose = (label: string): void => { updateDraft((current) => { - const selected = question.multiSelect === true - ? current.selected.includes(label) + if (question.multiSelect === true) { + const selected = current.selected.includes(label) ? current.selected.filter(item => item !== label) : [...current.selected, label] - : [label] - return { selected, custom: '', customOpen: false, skipped: false } + return { ...current, selected, skipped: false } + } + return { selected: [label], custom: '', customOpen: false, skipped: false } }) if (question.multiSelect !== true && index < questions.length - 1) { setIndex(current => current + 1) @@ -99,7 +100,12 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) { } const openCustom = (): void => { - updateDraft(current => ({ ...current, selected: [], customOpen: true, skipped: false })) + updateDraft(current => ({ + ...current, + selected: question.multiSelect === true ? current.selected : [], + customOpen: true, + skipped: false, + })) } const answered = (item: DraftAnswer): boolean => @@ -121,7 +127,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) { const custom = value.custom.trim() return { id: item.id, - selected: custom === '' ? value.selected : [], + selected: custom === '' || item.multiSelect === true ? value.selected : [], ...(custom === '' ? {} : { custom }), } }), @@ -269,7 +275,11 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) { onChange={(event) => { const value = event.target.value updateDraft(current => ({ - ...current, selected: [], custom: value, customOpen: true, skipped: false, + ...current, + selected: question.multiSelect === true ? current.selected : [], + custom: value, + customOpen: true, + skipped: false, })) }} onKeyDown={(event) => { diff --git a/packages/client/ui-question/tests/question-composer.spec.tsx b/packages/client/ui-question/tests/question-composer.spec.tsx index 7df9f2bde9..3154eba0ee 100644 --- a/packages/client/ui-question/tests/question-composer.spec.tsx +++ b/packages/client/ui-question/tests/question-composer.spec.tsx @@ -96,13 +96,20 @@ describe('QuestionComposer', () => { fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' })) fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' })) fireEvent.click(screen.getByRole('checkbox', { name: '代码质量' })) - fireEvent.keyDown(screen.getByRole('checkbox', { name: '代码质量' }), { key: 'Enter' }) + fireEvent.click(screen.getByRole('button', { name: '其他,请填写自定义答案' })) + const multiCustom = screen.getByPlaceholderText('输入你的答案') + fireEvent.change(multiCustom, { target: { value: '沟通能力' } }) + fireEvent.click(screen.getByRole('checkbox', { name: '产品判断' })) + expect(screen.getByRole('checkbox', { name: '系统设计' }).getAttribute('aria-checked')).toBe('true') + expect(screen.getByRole('checkbox', { name: '代码质量' }).getAttribute('aria-checked')).toBe('true') + expect((multiCustom as HTMLTextAreaElement).value).toBe('沟通能力') + fireEvent.keyDown(multiCustom, { key: 'Enter' }) // The domain face encoded the whole batch into one carrier envelope. expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [ { id: 'profile', selected: ['工程落地型 (Recommended)'] }, { id: 'detail', selected: [], custom: '要能独立排查线上问题' }, - { id: 'signals', selected: ['系统设计', '代码质量'] }, + { id: 'signals', selected: ['系统设计', '代码质量', '产品判断'], custom: '沟通能力' }, ])) expect(screen.getByRole('button', { name: '正在提交…' }).disabled).toBe(true) }) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 73b0845370..258ee74183 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: ca4471454f5be5d3fcba38ce665d4fb3fbd85e74 -README.zh.md: 953539e1198a52b2bf7cdd9ca1b0d263cc2ae6f9 +README.md: d517608404239809df03b089e150dbbecbf6d7cc +README.zh.md: f37427205fc72ef60f923d9d938adee0d4aa241c diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index ca4471454f..d517608404 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -10,6 +10,8 @@ Wire messages form a four-quadrant discriminated union — who initiates × requ The layering/protocol decisions are recorded in the [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md); the browser-side consumption architecture in the [web client architecture RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md). +Question responses are validated against their pending request before the first answer claims it. A multi-select item may carry both requested option labels in `selected` and non-empty `custom` text; a single-select item must use one or the other. Duplicate labels, unknown labels, mismatched ids, incomplete batches, and empty custom text are rejected as `bad-response`. + `session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key (the bespoke `session/title` frame is retired). Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 953539e119..f37427205f 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -10,6 +10,8 @@ 分层与协议决策记录在 [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中;浏览器侧消费架构记录在 [Web 客户端架构 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)中。 +首个回答认领待处理请求之前,系统会对照该请求校验问题响应。多选题的回答项可以同时携带 `selected` 中的请求选项标签与非空 `custom` 文本;单选题的回答项必须二选一。标签重复、标签未知、id 不匹配、批次不完整以及自定义文本为空都会以 `bad-response` 拒绝。 + `session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元铸造一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。 会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index f178bfefd0..585a6df305 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -275,7 +275,7 @@ function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQues if (new Set(answer.selected).size !== answer.selected.length) return false const custom = answer.custom?.trim() if (custom !== undefined && custom === '') return false - if (custom !== undefined && answer.selected.length > 0) return false + if (custom !== undefined && answer.selected.length > 0 && question.multiSelect !== true) return false if (question.multiSelect !== true && answer.selected.length > 1) return false const labels = new Set(question.options?.map(option => option.label) ?? []) return answer.selected.every(label => labels.has(label)) diff --git a/packages/host/apiproxy/tests/api-proxy-question.spec.ts b/packages/host/apiproxy/tests/api-proxy-question.spec.ts new file mode 100644 index 0000000000..e8eaae813f --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-question.spec.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore from '@deepseek-ai/dsh-session' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import type { ApiProxy, MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api' +import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import { createApiProxy } from '../src/api-proxy.ts' + +async function harness(): Promise<{ ctx: Context; api: ApiProxy }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(UserInteractionService) + return { + ctx, + api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }), + } +} + +function agent(id: string): Agent { + return { id } as unknown as Agent +} + +function openMux(api: ApiProxy, abort: AbortController): { + envelopes: RpcRequest[] + waitForQuestion(): Promise>> +} { + const envelopes: RpcRequest[] = [] + let resolveQuestion!: (value: RpcRequest>) => void + const question = new Promise>>((resolve) => { + resolveQuestion = resolve + }) + void (async () => { + for await (const envelope of api.events.mux({ rpcId: RpcId('question-mux'), payload: {} }, abort.signal)) { + envelopes.push(envelope) + if (envelope.payload.type === 'question/requested') { + resolveQuestion(envelope as RpcRequest>) + } + } + })() + return { envelopes, waitForQuestion: () => question } +} + +function answer( + envelope: RpcRequest>, + selected: string[], + custom?: string, +): Parameters[0] { + return { + type: 'client-response', + rpcId: envelope.rpcId, + result: { + ok: true, + value: { + sessionId: envelope.payload.sessionId, + answer: { + answers: [{ + id: envelope.payload.questions[0]?.id, + selected, + ...custom === undefined ? {} : { custom }, + }], + }, + }, + }, + } +} + +describe('question response validation', () => { + it('accepts selected options with custom text for multi-select questions', async () => { + const { ctx, api } = await harness() + const abort = new AbortController() + const mux = openMux(api, abort) + const asked = ctx.userInteraction.ask({ + agent: agent('session-multi'), + questions: [{ + id: 'targets', + question: 'Choose targets and add another', + multiSelect: true, + options: [{ label: 'Code' }, { label: 'Docs' }], + }], + }) + const envelope = await mux.waitForQuestion() + + expect(await api.respond(answer(envelope, ['Code', 'Docs'], 'Release notes'))) + .toEqual({ accepted: true }) + await expect(asked).resolves.toEqual({ + answers: [{ id: 'targets', selected: ['Code', 'Docs'], custom: 'Release notes' }], + }) + expect(mux.envelopes.some(item => item.payload.type === 'question/resolved')).toBe(true) + abort.abort() + }) + + it('keeps selected options and custom text mutually exclusive for single-select questions', async () => { + const { ctx, api } = await harness() + const abort = new AbortController() + const mux = openMux(api, abort) + const asked = ctx.userInteraction.ask({ + agent: agent('session-single'), + questions: [{ + id: 'target', + question: 'Choose one target', + options: [{ label: 'Code' }, { label: 'Docs' }], + }], + }) + const envelope = await mux.waitForQuestion() + + expect(await api.respond(answer(envelope, ['Code'], 'Release notes'))) + .toEqual({ accepted: false, reason: 'bad-response' }) + expect(await api.respond(answer(envelope, [], 'Release notes'))) + .toEqual({ accepted: true }) + await expect(asked).resolves.toEqual({ + answers: [{ id: 'target', selected: [], custom: 'Release notes' }], + }) + abort.abort() + }) +}) diff --git a/packages/ui/tool-ask-user/README.i18n.yaml b/packages/ui/tool-ask-user/README.i18n.yaml index a03a7326fa..09c111ba9d 100644 --- a/packages/ui/tool-ask-user/README.i18n.yaml +++ b/packages/ui/tool-ask-user/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 8e779f4025c20cd200344efb7cb8cd6bc09ba64d -README.zh.md: fe1dc5559882532c4f44e705cc6daa2c7f4f8905 +# pnpm run verify-translation-pairing --write packages/ui/tool-ask-user/README.md +README.md: 64da4d75d01a0df0ae51b1557ed1c796317b906f +README.zh.md: 8a1eb3ee4f9e9ccc2ea2fe433bf85158c76d3549 diff --git a/packages/ui/tool-ask-user/README.md b/packages/ui/tool-ask-user/README.md index 8e779f4025..64da4d75d0 100644 --- a/packages/ui/tool-ask-user/README.md +++ b/packages/ui/tool-ask-user/README.md @@ -15,7 +15,7 @@ Model-facing `ask_user_question` tool over `ctx.userInteraction`. It lets the mo - `options` — optional choices with `label` and `description`. If recommending a choice, put it first and append `(Recommended)` to that label. - `multi_select` — whether that question may return more than one selected option. -The tool calls `ctx.userInteraction.ask()` and returns canonical `{ answers: [{ id, selected, custom? }] }`. `selected` contains option labels; `custom` is present only for a free-form answer and overrides selected choices. The Native renderer preserves the compact JSON text shape `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`. +The tool calls `ctx.userInteraction.ask()` and returns canonical `{ answers: [{ id, selected, custom? }] }`. `selected` contains option labels; `custom` carries a free-form answer, supplementing `selected` for a multi-select question and overriding it for a single-select question. The Native renderer preserves the compact JSON text shape `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`. ## Role diff --git a/packages/ui/tool-ask-user/README.zh.md b/packages/ui/tool-ask-user/README.zh.md index fe1dc55598..8a1eb3ee4f 100644 --- a/packages/ui/tool-ask-user/README.zh.md +++ b/packages/ui/tool-ask-user/README.zh.md @@ -15,7 +15,7 @@ - `options`:可选选项,包含 `label` 和 `description`。如需推荐某个选项,请将其置于首位,并在该标签末尾追加 `(Recommended)`。 - `multi_select`:该问题是否可以返回多个选中的选项。 -工具调用 `ctx.userInteraction.ask()`,并返回规范的 `{ answers: [{ id, selected, custom? }] }`。`selected` 包含选项标签;仅当用户自由填写回答时才会出现 `custom`,并覆盖选中的选项。Native renderer 会保留紧凑的 JSON 文本形式 `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`。 +工具调用 `ctx.userInteraction.ask()`,并返回规范的 `{ answers: [{ id, selected, custom? }] }`。`selected` 包含选项标签;`custom` 携带自由填写的回答,对于多选题会补充 `selected`,对于单选题则会覆盖它。Native renderer 会保留紧凑的 JSON 文本形式 `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`。 ## 职责 diff --git a/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts b/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts index 395986aed1..7d019a520a 100644 --- a/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts +++ b/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts @@ -140,7 +140,7 @@ describe('ask_user_question tool', () => { async ask() { return { answers: [ - { id: 'targets', selected: ['tests', 'docs'] }, + { id: 'targets', selected: ['tests', 'docs'], custom: 'release notes' }, { id: 'notes', selected: [], custom: 'ship today' }, ], } @@ -168,13 +168,13 @@ describe('ask_user_question tool', () => { if (result.isError) throw new Error('expected ask_user_question success') expect(result.value).toEqual({ answers: [ - { id: 'targets', selected: ['tests', 'docs'] }, + { id: 'targets', selected: ['tests', 'docs'], custom: 'release notes' }, { id: 'notes', selected: [], custom: 'ship today' }, ], }) expect(result.content).toEqual([{ type: 'text', - text: '{"answers":[{"id":"targets","selected":["tests","docs"]},{"id":"notes","selected":[],"custom":"ship today"}]}', + text: '{"answers":[{"id":"targets","selected":["tests","docs"],"custom":"release notes"},{"id":"notes","selected":[],"custom":"ship today"}]}', }]) }) diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index 8ab63910fa..b62fb70da3 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/tui/README.md -README.md: 0b358520b863f0b9ee7a128cf4807f582fc46d8d -README.zh.md: 7e89197bd82d16dfbabeb715e953275e2f6dd68b +README.md: 3c847828a3d560b85e74809f984bc9ea581e417f +README.zh.md: 8872484a5de376e41564756332200198e587d2b3 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 0b358520b8..3c847828a3 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -153,7 +153,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -When a consumer calls `ctx.userInteraction.ask()`, this provider presents each question in order and returns selected option labels or `custom` text. Abort, cancellation, or UI disposal becomes `Error: ask_user_question was interrupted before the user answered` through `dsh-tool-ask-user`. +When a consumer calls `ctx.userInteraction.ask()`, this provider presents each question in order and returns selected option labels, `custom` text, or both for a multi-select question. Abort, cancellation, or UI disposal becomes `Error: ask_user_question was interrupted before the user answered` through `dsh-tool-ask-user`. #### Token effect diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index 7e89197bd8..8872484a5d 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -153,7 +153,7 @@ Paths prefixed with @ are files explicitly referenced by the user. Use the read #### 模型看到的内容 -消费方调用 `ctx.userInteraction.ask()` 时,此提供方会按顺序显示各个问题,并返回选中选项标签或 `custom` 文本。中止、取消或 UI dispose 会变为 `Error: ask_user_question was interrupted before the user answered`;该转换由 `dsh-tool-ask-user` 完成。 +消费方调用 `ctx.userInteraction.ask()` 时,此提供方会按顺序显示各个问题,并返回选中选项标签、`custom` 文本,或为多选题同时返回两者。中止、取消或 UI dispose 会变为 `Error: ask_user_question was interrupted before the user answered`;该转换由 `dsh-tool-ask-user` 完成。 #### Token 影响 diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts index 5e9237574a..ffdfb83c5a 100644 --- a/packages/ui/tui/src/components/dialogs.ts +++ b/packages/ui/tui/src/components/dialogs.ts @@ -799,12 +799,14 @@ export class QuestionDialog implements Component, Focusable { if (this.selected.has(this.selectedIndex)) this.selected.delete(this.selectedIndex) else this.selected.add(this.selectedIndex) } else if (matchesKey(data, Key.enter)) { - const indices = this.question.multiSelect ? [...this.selected].sort((a, b) => a - b) : [this.selectedIndex] - if (indices.length === 0) { + const selected = this.question.multiSelect + ? this.selectedOptionLabels() + : [options[this.selectedIndex]?.label].filter((label): label is string => label !== undefined) + if (selected.length === 0) { this.error = 'Select at least one option, or press Tab for a custom answer.' return } - this.done({ selected: indices.map(index => options[index]?.label).filter((label): label is string => label !== undefined) }) + this.done({ selected }) } else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') { this.mode = 'custom' this.error = '' @@ -819,7 +821,17 @@ export class QuestionDialog implements Component, Focusable { this.error = 'Enter an answer before submitting.' return } - this.done({ selected: [], custom }) + this.done({ + selected: this.question.multiSelect ? this.selectedOptionLabels() : [], + custom, + }) + } + + private selectedOptionLabels(): string[] { + return [...this.selected] + .sort((a, b) => a - b) + .map(index => this.options[index]?.label) + .filter((label): label is string => label !== undefined) } render(width: number): string[] { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index e918f9b299..1ee0b4fe38 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -4436,8 +4436,12 @@ describe('TUI user-interaction dialogs', () => { result.terminal.send(' ') result.terminal.send('\x1b[B') result.terminal.send(' ') + result.terminal.send('\t') + result.terminal.send('Tests') result.terminal.send('\r') - await expect(multi).resolves.toEqual({ answers: [{ id: 'targets', selected: ['Code', 'Docs'] }] }) + await expect(multi).resolves.toEqual({ + answers: [{ id: 'targets', selected: ['Code', 'Docs'], custom: 'Tests' }], + }) const custom = result.ctx.userInteraction.ask({ questions: [{ id: 'other', question: 'Choose or type', options: [{ label: 'Default' }] }], diff --git a/packages/ui/user-interaction/README.i18n.yaml b/packages/ui/user-interaction/README.i18n.yaml index 2a3b525012..c9ff2845e5 100644 --- a/packages/ui/user-interaction/README.i18n.yaml +++ b/packages/ui/user-interaction/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: d234d6677bdd772f1bbd2c979c0d41f90aef5c32 -README.zh.md: b70a61d6491e0bb0e52215cdeaeea3d728f7f153 +# pnpm run verify-translation-pairing --write packages/ui/user-interaction/README.md +README.md: 2ff29f5fd6244ebcf7e29b86f5de1cde30944532 +README.zh.md: 7d0d1b06db5be4353e42d1905c71d5dff963b97d diff --git a/packages/ui/user-interaction/README.md b/packages/ui/user-interaction/README.md index d234d6677b..2ff29f5fd6 100644 --- a/packages/ui/user-interaction/README.md +++ b/packages/ui/user-interaction/README.md @@ -19,7 +19,7 @@ Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a mod - `UserInteractionProvider` — UI implementation with `ask(request)`. - `UserInteractionError` — `HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`. -When an answer includes `custom`, `selected` is empty; custom text is an override rather than a supplement to selected choices. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch. +For a single-select question, `custom` overrides the selected choice and `selected` is empty. For a multi-select question, `custom` may supplement the labels in `selected`. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch. ## Role diff --git a/packages/ui/user-interaction/README.zh.md b/packages/ui/user-interaction/README.zh.md index b70a61d649..7d0d1b06db 100644 --- a/packages/ui/user-interaction/README.zh.md +++ b/packages/ui/user-interaction/README.zh.md @@ -19,7 +19,7 @@ - `UserInteractionProvider`:包含 `ask(request)` 的 UI 实现。 - `UserInteractionError`:`HarnessError` 的子类,包含 `EMPTY_QUESTIONS`、`NO_PROVIDER`、`DUPLICATE_PROVIDER` 和 `ASK_ABORTED` 等代码。 -当回答包含 `custom` 时,`selected` 为空;自定义文本会覆盖所选选项,而不是补充它们。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。 +对于单选题,`custom` 会覆盖选中的选项,且 `selected` 为空。对于多选题,`custom` 可以补充 `selected` 中的标签。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。 ## 职责 diff --git a/packages/ui/user-interaction/src/types.ts b/packages/ui/user-interaction/src/types.ts index ddf3e43489..435782a8f5 100644 --- a/packages/ui/user-interaction/src/types.ts +++ b/packages/ui/user-interaction/src/types.ts @@ -33,7 +33,7 @@ export interface AskUserQuestionItem { export interface AskUserQuestionAnswerItem { /** The answered question id. */ id: string - /** Selected option labels. Empty for custom or unanswered choices. */ + /** Selected option labels. May accompany custom text for a multi-select question. */ selected: string[] /** Optional free-text "Other" answer. */ custom?: string From 7401587ac26e5b15774c24c263db90206e6c400b Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:12:48 -0700 Subject: [PATCH 02/52] fix(ui-workspace): show approval-waiting sessions --- packages/client/ui-workspace/README.i18n.yaml | 4 +-- packages/client/ui-workspace/README.md | 2 ++ packages/client/ui-workspace/README.zh.md | 2 ++ .../ui-workspace/src/client/rows/Rows.tsx | 17 ++++++--- .../client/ui-workspace/src/client/tree.ts | 3 ++ .../client/ui-workspace/tests/rows.spec.tsx | 36 +++++++++++++++---- .../client/ui-workspace/tests/tree.spec.ts | 8 +++++ 7 files changed, 59 insertions(+), 13 deletions(-) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 536911a16a..bada1e738d 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md -README.md: a1b58f4abe0925be3b426d10344777e46caa9ba0 -README.zh.md: a472507bc45549c8feb55a75d294cbd7b3138cc5 +README.md: 1497f816a295e2cd156af9b779bce0b42759e1c7 +README.zh.md: be496412db9790b0625b40f0bbb06c1d406af015 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index a1b58f4abe..1497f816a2 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -6,6 +6,8 @@ Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sideba The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. +Session rows project the runtime's live `waitingApproval` fact: an amber warning dot takes precedence over the blue running indicator, and the hover card reports **Waiting for approval** until the request is resolved. Running and idle presentation is unchanged when no approval is pending. + Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored. ## Model Experience diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index a472507bc4..be496412db 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -6,6 +6,8 @@ 该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。 +Session 行会投影 runtime 的实时 `waitingApproval` 状态:琥珀色警告点优先于蓝色运行指示器,hover 卡片在请求解决前显示 **Waiting for approval**。没有待审批请求时,运行与空闲展示保持不变。 + 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 ## 模型体验 diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index d75fabdd8b..4f823d531f 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -121,15 +121,23 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions }: { * @param props.onToggle - unfold/fold a subtree by id. * @returns the node's row followed by its children. */ -/** Hover-card body: full title, relative time, and the status line (running/idle until wire status lands). */ +/** Session status presentation; approval waiting outranks the underlying running state. */ +function sessionStatus(node: SessionNode): { state: 'warning' | 'ongoing' | 'done'; label: string } { + if (node.waitingApproval) return { state: 'warning', label: 'Waiting for approval' } + if (node.running) return { state: 'ongoing', label: 'Running' } + return { state: 'done', label: 'Idle' } +} + +/** Hover-card body: full title, relative time, and approval/running/idle status. */ function SessionHoverContent({ node, now }: { node: SessionNode; now: number }) { + const status = sessionStatus(node) return (
{node.title}
{`${formatRelativeTime(node.updatedAt, now)} ago`}
- - {node.running ? 'Running' : 'Idle'} + + {status.label}
) @@ -175,6 +183,7 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename, }) { const row = node const selected = node.id === currentId + const status = sessionStatus(node) const [menuOpen, setMenuOpen] = useState(false) // Rail (figma session cell: pad 8, twist slot 16, status slot 16, gap 4 to // the title): both slots are always reserved so titles align whether or not @@ -226,7 +235,7 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename, ) : null} - {row.running && } + {(row.waitingApproval || row.running) && } {row.title} {formatRelativeTime(row.updatedAt, now)} diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index c0adfadd6f..af2c6cd051 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -20,6 +20,8 @@ export interface SessionNode { /** The session HAS children in the data (the twist renders even while folded). */ hasChildren: boolean expanded: boolean + /** A pending approval takes display precedence over the running state. */ + waitingApproval: boolean running: boolean updatedAt: number } @@ -183,6 +185,7 @@ function sessionNode(s: SessionSummary, children: readonly SessionNode[], hasChi children, hasChildren, expanded, + waitingApproval: s.waitingApproval, running: s.running, updatedAt: s.updatedAt, } diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index bfaa8a36dd..0b6837c0bc 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -59,11 +59,11 @@ describe('workspace browser rows', () => { it('renders and operates selected, running, recursive Session nodes', () => { const child: SessionNode = { id: sid('child'), title: 'Child', children: [], hasChildren: false, - expanded: false, running: false, updatedAt: 0, + expanded: false, waitingApproval: false, running: false, updatedAt: 0, } const parent: SessionNode = { id: sid('parent'), title: 'Parent', children: [child], hasChildren: true, - expanded: true, running: true, updatedAt: 0, + expanded: true, waitingApproval: false, running: true, updatedAt: 0, } const onOpen = vi.fn() const onToggle = vi.fn() @@ -142,7 +142,7 @@ describe('workspace browser rows', () => { const onRename = vi.fn() const node: SessionNode = { id: sid('s1'), title: 'One', children: [], hasChildren: false, - expanded: false, running: false, updatedAt: 0, + expanded: false, waitingApproval: false, running: false, updatedAt: 0, } render() @@ -169,7 +169,7 @@ describe('workspace browser rows', () => { it('flat variant renders no twist even for a parent and ignores toggling', () => { const node: SessionNode = { id: sid('p'), title: 'Parent', children: [], hasChildren: true, - expanded: false, running: false, updatedAt: 0, + expanded: false, waitingApproval: false, running: false, updatedAt: 0, } render() @@ -181,7 +181,7 @@ describe('workspace browser rows', () => { try { const node: SessionNode = { id: sid('s1'), title: 'Hovered', children: [], hasChildren: false, - expanded: false, running: true, updatedAt: 0, + expanded: false, waitingApproval: false, running: true, updatedAt: 0, } render() @@ -203,12 +203,34 @@ describe('workspace browser rows', () => { } }) + it('shows approval waiting as warning ahead of the running state', () => { + vi.useFakeTimers() + try { + const node: SessionNode = { + id: sid('approval'), title: 'Needs approval', children: [], hasChildren: false, + expanded: false, waitingApproval: true, running: true, updatedAt: 0, + } + render() + const row = screen.getByRole('treeitem') + expect(row.querySelector('[data-state="warning"]')).toBeTruthy() + expect(row.querySelector('[data-state="ongoing"]')).toBeNull() + + fireEvent.pointerEnter(row.parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('Waiting for approval')).toBeTruthy() + expect(document.querySelectorAll('[data-state="warning"]')).toHaveLength(2) + } finally { + vi.useRealTimers() + } + }) + it('idle hover card shows the Idle status line', () => { vi.useFakeTimers() try { const node: SessionNode = { id: sid('s1'), title: 'Quiet', children: [], hasChildren: false, - expanded: false, running: false, updatedAt: 0, + expanded: false, waitingApproval: false, running: false, updatedAt: 0, } render() @@ -224,7 +246,7 @@ describe('workspace browser rows', () => { it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => { const node: SessionNode = { id: sid('s1'), title: 'Drag me', children: [], hasChildren: false, - expanded: false, running: false, updatedAt: 0, + expanded: false, waitingApproval: false, running: false, updatedAt: 0, } const inactive = dragProps() const { rerender } = render( diff --git a/packages/client/ui-workspace/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts index eb34f633d8..2af6c1a6ab 100644 --- a/packages/client/ui-workspace/tests/tree.spec.ts +++ b/packages/client/ui-workspace/tests/tree.spec.ts @@ -33,6 +33,14 @@ describe('deriveGroups', () => { expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')]) }) + it('projects approval-waiting state into grouped and flat rows', () => { + const awaiting = { ...summary('awaiting', 10), waitingApproval: true, running: true } + const sessions = list(awaiting) + const grouped = deriveGroups(sessions, [workspace('project', ['awaiting'])], view(['project'])) + expect(grouped[0]!.sessions[0]).toMatchObject({ waitingApproval: true, running: true }) + expect(deriveFlat(sessions, { query: '' })[0]).toMatchObject({ waitingApproval: true, running: true }) + }) + it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => { const sessions = list(summary('owned', 1, '/projects/first'), summary('loose', 9, '/other')) const groups = deriveGroups(sessions, [workspace('first', ['owned'])], view([UNGROUPED_KEY])) From 61803f1a462467d49ec06b1f1b107ba00e40bf03 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:07:36 -0700 Subject: [PATCH 03/52] fix(ui-workspace): expose session status accessibly --- packages/client/ui-sidebar/README.i18n.yaml | 4 +-- packages/client/ui-sidebar/README.md | 2 +- packages/client/ui-sidebar/README.zh.md | 2 +- packages/client/ui-workspace/README.i18n.yaml | 4 +-- packages/client/ui-workspace/README.md | 3 +- packages/client/ui-workspace/README.zh.md | 3 +- .../src/client/rows/Rows.module.css | 9 +++++ .../ui-workspace/src/client/rows/Rows.tsx | 36 ++++++++++++------- .../client/ui-workspace/tests/rows.spec.tsx | 15 +++++--- 9 files changed, 52 insertions(+), 26 deletions(-) diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index 6c5f1735e3..00b33602d0 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar/README.md -README.md: 93a1f15a5802f94a0ebe930dda1dbd4fbc7343c9 -README.zh.md: 8c8545a5d7d8cb4d58772abf867d7ee82c31bf1d +README.md: d2c0c3332f2202986f1daf3a45c84cc1e65eee6d +README.zh.md: 03cb86842d8a28f3a18250a9d77dd0a0a217d7b9 diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 93a1f15a58..d2c0c3332f 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -22,6 +22,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **State dots have two live data states (running/none)** — the done/error/amber sources arrive with P-II approvals and notifications; the four-color primitive is already wired. +- **State dots have approval-waiting/running/none live states** — approval waiting is amber and outranks running; done/error notification sources remain deferred. - **Group-by menu ships by-workspace only** — Update/Status grouping strategies are drawn without specs and deferred. - **"New task completed" unread marking is local viewing state** — completion-time > last-seen never reaches the host. diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index 8c8545a5d7..03cb86842d 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -22,6 +22,6 @@ New Session 会启动运行时的页面局部前端 Session Intent;真实 Work ## 已知限制与暂缓事项 -- **状态点只有两种实时数据状态(running/none)**:done/error/amber 的数据源将随 P-II 审批与通知功能一并提供;四色原语已接入。 +- **状态点具有待审批/running/none 三种实时状态**:待审批使用琥珀色并优先于 running;done/error 的通知数据源仍暂缓实现。 - **分组选单只提供按 Workspace 分组**:Update/Status 分组策略只有图稿而没有规范,暂缓实现。 - **「New task completed」未读标记是本地查看状态**:完成时间 > 上次查看时间这一事实永远不会到达宿主。 diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index bada1e738d..27cb783db7 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md -README.md: 1497f816a295e2cd156af9b779bce0b42759e1c7 -README.zh.md: be496412db9790b0625b40f0bbb06c1d406af015 +README.md: 4ca836e4f1beeb164716e5fc4741253719d2700c +README.zh.md: 2a5448a12d58184b027c99b5301510370ba63a83 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 1497f816a2..4ca836e4f1 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -6,7 +6,7 @@ Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sideba The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. -Session rows project the runtime's live `waitingApproval` fact: an amber warning dot takes precedence over the blue running indicator, and the hover card reports **Waiting for approval** until the request is resolved. Running and idle presentation is unchanged when no approval is pending. +Session rows distinguish the runtime's live `waitingApproval` fact from an otherwise blue in-flight Session: an amber warning dot takes precedence over the running indicator, an accompanying visually hidden label exposes the state to assistive technology, and the hover card reports **Waiting for approval** until the request is resolved. Running and idle presentation is unchanged when no approval is pending. Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored. @@ -21,4 +21,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **No Session deletion or fork control** — the Session menu's Fork and Delete rows remain visual-only (Rename is wired); Workspace registration deletion does not delete Sessions. +- **Approval waiting is not aggregated into hidden ancestors** — a waiting child Session under a folded parent, or any waiting row inside a collapsed group, becomes visible only after that container is expanded. - **Native folder selection depends on the local Host carrier** — under the `-native` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index be496412db..2a5448a12d 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -6,7 +6,7 @@ 该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。 -Session 行会投影 runtime 的实时 `waitingApproval` 状态:琥珀色警告点优先于蓝色运行指示器,hover 卡片在请求解决前显示 **Waiting for approval**。没有待审批请求时,运行与空闲展示保持不变。 +Session 行会把 runtime 的实时 `waitingApproval` 状态与原本显示为蓝色的进行中 Session 区分开:琥珀色警告点优先于运行指示器,随附的视觉隐藏标签会向辅助技术公开这一状态,hover 卡片则在请求解决前显示 **Waiting for approval**。没有待审批请求时,运行与空闲展示保持不变。 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 @@ -21,4 +21,5 @@ Session 行会投影 runtime 的实时 `waitingApproval` 状态:琥珀色警 ## 已知限制与暂缓事项 - **没有 Session 删除与 fork 控件**:Session 菜单的 Fork 与 Delete 行仍仅提供视觉效果(Rename 已接线);删除 Workspace 注册记录不会删除 Session。 +- **待审批状态不会聚合到隐藏的祖先节点**:折叠父节点下正在等待的子 Session,或折叠分组内的任何等待行,只有在对应容器展开后才可见。 - **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。可远程的选取是 `-browse` 组合的应用内流程。 diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css index 7b19284b66..6d5e90beeb 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -68,6 +68,15 @@ color: var(--dsw-alias-label-tertiary); } +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + .folderActive { color: var(--dsw-alias-state-business-primary); diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 4f823d531f..92796c409e 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -109,18 +109,6 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions }: { ) } -/** - * One session subtree: the node's own 34px row (indent by depth, expand - * twist when it has children, running dot, relative time) plus its visible - * children, recursively — the component tree mirrors the derived tree. - * @param props.node - derived session node. - * @param props.depth - 0 = directly under the group header. - * @param props.currentId - selected session id (row highlight). - * @param props.now - epoch ms for relative-time formatting. - * @param props.onOpen - open a session by id. - * @param props.onToggle - unfold/fold a subtree by id. - * @returns the node's row followed by its children. - */ /** Session status presentation; approval waiting outranks the underlying running state. */ function sessionStatus(node: SessionNode): { state: 'warning' | 'ongoing' | 'done'; label: string } { if (node.waitingApproval) return { state: 'warning', label: 'Waiting for approval' } @@ -167,6 +155,21 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after' } +/** + * One session subtree: the node's own 34px row (indent by depth, expand + * twist when it has children, status dot, relative time) plus its visible + * children, recursively — the component tree mirrors the derived tree. + * @param props.node - derived session node. + * @param props.depth - 0 = directly under the group header. + * @param props.currentId - selected session id (row highlight). + * @param props.now - epoch ms for relative-time formatting. + * @param props.onOpen - open a session by id. + * @param props.onRename - rename a session by id and current title. + * @param props.onToggle - unfold/fold a subtree by id. + * @param props.drag - optional root-row drag wiring. + * @param props.flat - omit tree indentation controls for a flat list. + * @returns the node's row followed by its children. + */ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename, onToggle, drag, flat = false }: { node: SessionNode depth: number @@ -235,7 +238,14 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename, ) : null} - {(row.waitingApproval || row.running) && } + + {status.state !== 'done' && ( + <> + + {status.label} + + )} + {row.title} {formatRelativeTime(row.updatedAt, now)} diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index 0b6837c0bc..f9caa54c0b 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -191,7 +191,7 @@ describe('workspace browser rows', () => { // Card body: full title + relative time + running status. expect(screen.getAllByText('Hovered')).toHaveLength(2) expect(screen.getByText('1min ago')).toBeTruthy() - expect(screen.getByText('Running')).toBeTruthy() + expect(screen.getAllByText('Running')).toHaveLength(2) fireEvent.pointerLeave(wrapper) // Menu open (disabled=true) suppresses the card for the same hover. fireEvent.click(screen.getByRole('button', { name: 'Session actions for Hovered' })) @@ -210,15 +210,20 @@ describe('workspace browser rows', () => { id: sid('approval'), title: 'Needs approval', children: [], hasChildren: false, expanded: false, waitingApproval: true, running: true, updatedAt: 0, } - render() const row = screen.getByRole('treeitem') expect(row.querySelector('[data-state="warning"]')).toBeTruthy() expect(row.querySelector('[data-state="ongoing"]')).toBeNull() - - fireEvent.pointerEnter(row.parentElement as HTMLElement) - act(() => { vi.advanceTimersByTime(500) }) expect(screen.getByText('Waiting for approval')).toBeTruthy() + + view.rerender() + expect(screen.getByRole('treeitem').querySelector('[data-state="warning"]')).toBeTruthy() + + fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getAllByText('Waiting for approval')).toHaveLength(2) expect(document.querySelectorAll('[data-state="warning"]')).toHaveLength(2) } finally { vi.useRealTimers() From 8014abffa011d4b8b4d983f27b0ce2a1776d0fa7 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:13:02 -0700 Subject: [PATCH 04/52] test(web): cover waiting approval in built graph --- apps/web/tests/built-boot.snapshot.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index 69d5d5cfae..018a9f2180 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -102,6 +102,14 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) await within(tree).findByText('4 sessions') + // The resident approval fixture proves the assembled workspace plugin + // distinguishes a blocked running session from an ordinarily busy one. + const waitingTitle = await within(tree).findByText('Fixture 历史会话') + const waitingRow = waitingTitle.closest('[role="treeitem"]') + expect(waitingRow?.querySelector('[data-state="warning"]')).not.toBeNull() + expect(waitingRow?.querySelector('[data-state="ongoing"]')).toBeNull() + expect(within(waitingRow as HTMLElement).getByText('Waiting for approval')).not.toBeNull() + // Opening a session reaches chat content through the fixture transport. fireEvent.click(await within(tree).findByText('Fixture 历史会话')) await waitFor(() => { From 472ba33cd941ace8d0ab15aa6f89932206926c3c Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:17:14 -0700 Subject: [PATCH 05/52] refactor(ui-workspace): reuse status dot vocabulary --- packages/client/ui-workspace/src/client/rows/Rows.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index f9bd7f3eaf..fbde2b9522 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -12,6 +12,7 @@ import { IconFolderClose16, IconFolderOpen16, IconPlusOutline16, IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' +import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' import type { GroupNode, SessionNode } from '../tree.ts' import { formatRelativeTime } from '../tree.ts' import css from './Rows.module.css' @@ -135,7 +136,7 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions }: { } /** Session status presentation; approval waiting outranks the underlying running state. */ -function sessionStatus(node: SessionNode): { state: 'warning' | 'ongoing' | 'done'; label: string } { +function sessionStatus(node: SessionNode): { state: StateDotState; label: string } { if (node.waitingApproval) return { state: 'warning', label: 'Waiting for approval' } if (node.running) return { state: 'ongoing', label: 'Running' } return { state: 'done', label: 'Idle' } From 31a498b1dbd3f8b658970426652a01175a537108 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 17:39:28 +0800 Subject: [PATCH 06/52] test(web): follow inline custom answer input --- packages/client/ui-question/tests/question-composer.spec.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/client/ui-question/tests/question-composer.spec.tsx b/packages/client/ui-question/tests/question-composer.spec.tsx index 40006c2c9c..87eb275b7d 100644 --- a/packages/client/ui-question/tests/question-composer.spec.tsx +++ b/packages/client/ui-question/tests/question-composer.spec.tsx @@ -103,13 +103,12 @@ describe('QuestionComposer', () => { fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' })) fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' })) fireEvent.click(screen.getByRole('checkbox', { name: '代码质量' })) - fireEvent.click(screen.getByRole('button', { name: '其他,请填写自定义答案' })) const multiCustom = screen.getByPlaceholderText('输入你的答案') fireEvent.change(multiCustom, { target: { value: '沟通能力' } }) fireEvent.click(screen.getByRole('checkbox', { name: '产品判断' })) expect(screen.getByRole('checkbox', { name: '系统设计' }).getAttribute('aria-checked')).toBe('true') expect(screen.getByRole('checkbox', { name: '代码质量' }).getAttribute('aria-checked')).toBe('true') - expect((multiCustom as HTMLTextAreaElement).value).toBe('沟通能力') + expect((multiCustom as HTMLInputElement).value).toBe('沟通能力') fireEvent.keyDown(multiCustom, { key: 'Enter' }) // The domain face encoded the whole batch into one carrier envelope. From 285cd60744e0fbebcec20e5f50605c3ea3dc7f8b Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:40:31 -0700 Subject: [PATCH 07/52] docs(ui-workspace): align approval status contracts --- apps/web/tests/built-boot.snapshot.ts | 20 ++++++++++--------- packages/client/ui-workspace/README.i18n.yaml | 4 ++-- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- .../client/ui-workspace/src/client/tree.ts | 2 +- 5 files changed, 16 insertions(+), 14 deletions(-) diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index 018a9f2180..d436d41866 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -6,10 +6,10 @@ // layers, per-plugin CSS injection, and a rendered journey reaching chat // content from the keyless FixtureApiClient transport. // -// Behavior assertions do NOT belong here: component and wiring behavior is -// pinned by the per-package suites (SlotTestRuntime benches over src), which -// this smoke's plugin set cannot influence — bundling, module-table -// resolution, and boot layering are the only failure modes left to it. +// Component behavior remains owned by per-package suites (SlotTestRuntime +// benches over src). This smoke additionally pins the resident approval +// fixture's cross-plugin projection because only the built connection/runtime/ +// workspace graph can prove that transport-to-row path end to end. import { readFileSync } from 'node:fs' import { join } from 'node:path' import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' @@ -105,13 +105,15 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn // The resident approval fixture proves the assembled workspace plugin // distinguishes a blocked running session from an ordinarily busy one. const waitingTitle = await within(tree).findByText('Fixture 历史会话') - const waitingRow = waitingTitle.closest('[role="treeitem"]') - expect(waitingRow?.querySelector('[data-state="warning"]')).not.toBeNull() - expect(waitingRow?.querySelector('[data-state="ongoing"]')).toBeNull() - expect(within(waitingRow as HTMLElement).getByText('Waiting for approval')).not.toBeNull() + const waitingRow = waitingTitle.closest('[role="treeitem"]') + expect(waitingRow).not.toBeNull() + if (waitingRow === null) throw new Error('fixture Session title must belong to a tree row') + expect(waitingRow.querySelector('[data-state="warning"]')).not.toBeNull() + expect(waitingRow.querySelector('[data-state="ongoing"]')).toBeNull() + expect(within(waitingRow).getByText('Waiting for approval')).not.toBeNull() // Opening a session reaches chat content through the fixture transport. - fireEvent.click(await within(tree).findByText('Fixture 历史会话')) + fireEvent.click(waitingTitle) await waitFor(() => { expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull() }, { timeout: 10_000 }) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 27cb783db7..25a2713cfb 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md -README.md: 4ca836e4f1beeb164716e5fc4741253719d2700c -README.zh.md: 2a5448a12d58184b027c99b5301510370ba63a83 +README.md: 7109de680f98ede4d8374444cf50b439317ce128 +README.zh.md: 874d9e3d190e0488d95362ce1eea260341d6a23e diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 4ca836e4f1..7109de680f 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -6,7 +6,7 @@ Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sideba The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. -Session rows distinguish the runtime's live `waitingApproval` fact from an otherwise blue in-flight Session: an amber warning dot takes precedence over the running indicator, an accompanying visually hidden label exposes the state to assistive technology, and the hover card reports **Waiting for approval** until the request is resolved. Running and idle presentation is unchanged when no approval is pending. +Session rows distinguish the runtime's live `waitingApproval` approval-request fact from an otherwise blue in-flight Session: an amber warning dot takes precedence over the running indicator, and the hover card reports **Waiting for approval** until the request is resolved. Every lit state carries a visually hidden label (`Waiting for approval` or `Running`) for assistive technology; an idle row leaves the reserved status slot empty. Question waits are tracked separately and do not set `waitingApproval`. Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 2a5448a12d..874d9e3d19 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -6,7 +6,7 @@ 该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。 -Session 行会把 runtime 的实时 `waitingApproval` 状态与原本显示为蓝色的进行中 Session 区分开:琥珀色警告点优先于运行指示器,随附的视觉隐藏标签会向辅助技术公开这一状态,hover 卡片则在请求解决前显示 **Waiting for approval**。没有待审批请求时,运行与空闲展示保持不变。 +Session 行会把 runtime 的实时 `waitingApproval` 审批请求状态与原本显示为蓝色的进行中 Session 区分开:琥珀色警告点优先于运行指示器,hover 卡片则在请求解决前显示 **Waiting for approval**。每种点亮状态都带有面向辅助技术的视觉隐藏标签(`Waiting for approval` 或 `Running`);空闲行会保留空的状态槽位。问题等待由另一套状态跟踪,不会设置 `waitingApproval`。 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 210148c72f..763818334f 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -20,7 +20,7 @@ export interface SessionNode { /** The session HAS children in the data (the twist renders even while folded). */ hasChildren: boolean expanded: boolean - /** A pending approval takes display precedence over the running state. */ + /** The runtime Session list reports a pending approval request for this Session. */ waitingApproval: boolean running: boolean updatedAt: number From 51711a37720144172125d6713330a886ddf65b6f Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:41:32 -0700 Subject: [PATCH 08/52] docs(ui-sidebar): defer session status ownership --- packages/client/ui-sidebar/README.i18n.yaml | 4 ++-- packages/client/ui-sidebar/README.md | 2 +- packages/client/ui-sidebar/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index 00b33602d0..c1f5d5df03 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar/README.md -README.md: d2c0c3332f2202986f1daf3a45c84cc1e65eee6d -README.zh.md: 03cb86842d8a28f3a18250a9d77dd0a0a217d7b9 +README.md: 19c2d1033de4475816249aa8429f4a589eeb6481 +README.zh.md: b8c154586570cf1b9fd4bf776bc09b36ab5ee7d2 diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index d2c0c3332f..19c2d1033d 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -22,6 +22,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **State dots have approval-waiting/running/none live states** — approval waiting is amber and outranks running; done/error notification sources remain deferred. +- **Session state-dot rendering is owned by [ui-workspace](../ui-workspace/README.md)** — done/error notification sources remain deferred. - **Group-by menu ships by-workspace only** — Update/Status grouping strategies are drawn without specs and deferred. - **"New task completed" unread marking is local viewing state** — completion-time > last-seen never reaches the host. diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index 03cb86842d..b8c1545865 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -22,6 +22,6 @@ New Session 会启动运行时的页面局部前端 Session Intent;真实 Work ## 已知限制与暂缓事项 -- **状态点具有待审批/running/none 三种实时状态**:待审批使用琥珀色并优先于 running;done/error 的通知数据源仍暂缓实现。 +- **Session 状态点渲染由 [ui-workspace](../ui-workspace/README.md) 持有**:done/error 的通知数据源仍暂缓实现。 - **分组选单只提供按 Workspace 分组**:Update/Status 分组策略只有图稿而没有规范,暂缓实现。 - **「New task completed」未读标记是本地查看状态**:完成时间 > 上次查看时间这一事实永远不会到达宿主。 From 3ba4d40e6a5fa670871f3fad176645a9913e9565 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 17:59:37 +0800 Subject: [PATCH 09/52] fix(user-interaction): address review feedback --- ...select-custom-answer-composition.i18n.yaml | 4 +- ...-multi-select-custom-answer-composition.md | 4 +- ...lti-select-custom-answer-composition.zh.md | 4 +- apps/web/tests/question-composer.e2e.ts | 37 +++++++++++++++---- .../question-composer/answered.expected.md | 3 +- .../question-composer/composed.expected.md | 17 +++++++++ .../snapshots/question-composer/session.jsonl | 12 +++--- .../question-composer/ui.expected.md | 6 +-- .../tests/question-composer.spec.tsx | 5 +++ packages/host/apiproxy/src/api-proxy.ts | 6 ++- .../tool-ask-user/tests/tool-ask-user.spec.ts | 10 ++++- packages/ui/tui/README.i18n.yaml | 4 +- packages/ui/tui/README.md | 2 +- packages/ui/tui/README.zh.md | 2 +- packages/ui/tui/src/components/dialogs.ts | 12 ++++-- packages/ui/tui/tests/tui.spec.ts | 28 ++++++++++++-- 16 files changed, 119 insertions(+), 37 deletions(-) create mode 100644 apps/web/tests/snapshots/question-composer/composed.expected.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.i18n.yaml index bb081e4be8..2f06390bdf 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.md -2026-07-30-multi-select-custom-answer-composition.md: 7194f4a79f1dd49eba4a9b626d75203fced06544 -2026-07-30-multi-select-custom-answer-composition.zh.md: fac09c8db0ebf2dd4a84ade7aa7868128656025d +2026-07-30-multi-select-custom-answer-composition.md: 581beec89a0f0018ec2df687f5dfe1b1b5b86d22 +2026-07-30-multi-select-custom-answer-composition.zh.md: 5c9cb59822aca3fbf49fbbdf522c76f963df3480 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.md b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.md index 7194f4a79f..581beec89a 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.md @@ -10,7 +10,7 @@ The user-interaction result vocabulary carries selected option labels and option ## Decision -For a question with `multiSelect: true`, one answer item may contain both a non-empty `selected` array and non-empty `custom` text. Web drafts preserve both values regardless of whether the user selects an option or types custom text first; the TUI projects its checked option set when custom text is submitted; and the Web host accepts the combined response after applying its existing id, label, uniqueness, batch, and non-empty-text validation. +For a question with `multiSelect: true`, one answer item may contain both a non-empty `selected` array and non-empty `custom` text. Web drafts preserve both values regardless of whether the user selects an option or types custom text first; the TUI retains pending custom text across option/custom mode switches and projects it with checked labels from either submit mode; and the Web host accepts the combined response after applying its existing id, label, uniqueness, batch, and non-empty-text validation. Single-select and optionless questions keep exclusive semantics: custom text overrides any selected option. The result shape remains `{ id, selected, custom? }`, so no wire or tool-output schema changes. @@ -22,4 +22,4 @@ Single-select and optionless questions keep exclusive semantics: custom text ove ## Consequences -Multi-select UIs can represent the user's complete answer without discarding either source. Providers and consumers retain the existing DTO, while request-aware validators interpret the allowed combination from `multiSelect`. Web, TUI, host-response, tool-projection, and assembled keyless TUI coverage pin the combined result; single-select host coverage pins the remaining exclusivity rule. +Multi-select UIs can represent the user's complete answer without discarding either source. Providers and consumers retain the existing DTO, while request-aware validators interpret the allowed combination from `multiSelect`. Web component and assembled-browser coverage, TUI coverage, host-response coverage, and tool-projection coverage pin the combined result. Web, TUI, and tool-projection coverage also retain labels-only answers; assembled keyless TUI coverage pins the combined terminal flow, and single-select host coverage pins the remaining exclusivity rule. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.zh.md index fac09c8db0..5c9cb59822 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -对于 `multiSelect: true` 的问题,一个回答项可以同时包含非空 `selected` 数组与非空 `custom` 文本。无论用户先选择选项还是先输入自定义文本,Web 草稿都会保留两个值;提交自定义文本时,TUI 会投影其已勾选的选项集合;Web 宿主则在应用现有的 id、标签、唯一性、批次和非空文本校验后接受组合响应。 +对于 `multiSelect: true` 的问题,一个回答项可以同时包含非空 `selected` 数组与非空 `custom` 文本。无论用户先选择选项还是先输入自定义文本,Web 草稿都会保留两个值;TUI 在选项与自定义模式之间切换时会保留待提交的自定义文本,并在任一模式提交时将其与已勾选的标签一同投影;Web 宿主则在应用现有的 id、标签、唯一性、批次和非空文本校验后接受组合响应。 单选题和无选项问题仍保持互斥语义:自定义文本会覆盖任何已选中的选项。结果形状仍为 `{ id, selected, custom? }`,因此协议或工具输出 schema 均无需变更。 @@ -22,4 +22,4 @@ Status: implemented ## 后果 -多选 UI 可以完整表达用户的回答,不会丢弃任一来源。提供方和消费方继续使用现有 DTO,而请求感知的校验器会根据 `multiSelect` 判断是否允许组合。Web、TUI、宿主响应、工具投影和组装后的无密钥 TUI 覆盖会固定组合结果;单选题的宿主覆盖则固定其余的互斥规则。 +多选 UI 可以完整表达用户的回答,不会丢弃任一来源。提供方和消费方继续使用现有 DTO,而请求感知的校验器会根据 `multiSelect` 判断是否允许组合。Web 组件与组装浏览器的覆盖率、TUI 覆盖率、宿主响应覆盖率和工具投影覆盖率共同固定组合结果。Web、TUI 与工具投影覆盖率还固定了仅含标签的回答形态;组装后的无密钥 TUI 覆盖率固定终端中的组合回答流程,单选题的宿主覆盖率则固定其余的互斥规则。 diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index ac4be25299..983f1c4812 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -23,15 +23,16 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/question-composer', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') -// Second golden: the answered transcript — the question resolved into its -// tool round trip and the final reply, the state the waiting golden cannot see. +const COMPOSED_EXPECTED = join(SNAPSHOT_DIR, 'composed.expected.md') +// Final golden: the answered transcript — the question resolved into its tool +// round trip and the final reply, the state the composer goldens cannot see. const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md') const MODE = webSnapshotMode() // The options carry long descriptions on purpose: the squeeze assertion below // needs option copy that WRAPS, which is the only shape that reproduces a // collapsed row painting its copy outside its own box. -const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." After I answer, reply with the single word DONE and stop.' +const PROMPT = 'Use the ask_user_question tool to ask me exactly one multi-select question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." Set multi_select to true. After I answer, reply with the single word DONE and stop.' describe('web e2e: resident question composer round trip', () => { let scaffold: WebScaffold @@ -124,9 +125,17 @@ describe('web e2e: resident question composer round trip', () => { await page.setViewportSize(original) } - await composer.getByRole('radio', { name: 'Blue' }).click() - // Submit: Enter on the focused option (the composer's documented submit). - await composer.getByRole('radio', { name: 'Blue' }).press('Enter') + const blue = composer.getByRole('checkbox', { name: 'Blue' }) + await blue.click() + const custom = composer.getByRole('textbox') + await custom.fill('Include accessibility notes') + expect(await blue.getAttribute('aria-checked')).toBe('true') + expect(await custom.inputValue()).toBe('Include accessibility notes') + if (MODE !== 'record') { + const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd) + await compareOrRefreshGolden(COMPOSED_EXPECTED, snapshot, MODE) + } + await custom.press('Enter') const sessionId = await settled if (MODE === 'record') { @@ -135,7 +144,14 @@ describe('web e2e: resident question composer round trip', () => { } // World state: the tool result carries the chosen answer, and DONE lands. const results = sessionEvents.filter(e => e.type === 'tool/result') - expect(JSON.stringify(results.at(-1))).toContain('Blue') + const answerText = results.flatMap(event => event.data.message.content.flatMap(block => + block.type === 'tool-result' + ? block.content.filter(item => item.type === 'text').map(item => item.text) + : [], + )).at(-1) + expect(JSON.parse(answerText ?? '')).toEqual({ + answers: [{ id: 'color', selected: ['Blue'], custom: 'Include accessibility notes' }], + }) await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) // Composer gone; regular input restored. expect(await page.locator('[data-question-key]').count()).toBe(0) @@ -149,6 +165,11 @@ describe('web e2e: resident question composer round trip', () => { }, 200_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md', 'answered.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, [ + 'session.jsonl', + 'ui.expected.md', + 'composed.expected.md', + 'answered.expected.md', + ]) }) }) diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index 36752c783a..7f7603eb8a 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -4,13 +4,14 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- text: "Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop. {{clock}}" +- text: "Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop. {{clock}}" - button "复制": - img - button "在新对话中分支": - img - button "编辑": - img +- button "▸ 上下文注入" - button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.": - img - img diff --git a/apps/web/tests/snapshots/question-composer/composed.expected.md b/apps/web/tests/snapshots/question-composer/composed.expected.md new file mode 100644 index 0000000000..c18e6225c6 --- /dev/null +++ b/apps/web/tests/snapshots/question-composer/composed.expected.md @@ -0,0 +1,17 @@ +- region "Which color do you prefer?": + - text: Pick one + - heading "Which color do you prefer?" [level=2] + - button "Dismiss all questions": + - img + - group: + - checkbox "Blue" [checked]: Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards. + - checkbox "Green": Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. + - textbox "Type your answer": Include accessibility notes + - button "Previous question" [disabled]: + - img + - text: 1 / 1 + - button "Next question" [disabled]: + - img + - status + - button "Skip this question" + - button "Submit" diff --git a/apps/web/tests/snapshots/question-composer/session.jsonl b/apps/web/tests/snapshots/question-composer/session.jsonl index b13a84e22c..0a5107d23f 100644 --- a/apps/web/tests/snapshots/question-composer/session.jsonl +++ b/apps/web/tests/snapshots/question-composer/session.jsonl @@ -1,20 +1,20 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785150167878,"cwd":"{{cwd}}/workspace"} {"type":"turn/start","seq":0,"time":1785150167924,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} -{"type":"user/message","seq":1,"time":1785150167925,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"6deba879-8787-4853-a5f2-0d108a08eb2d"},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1785150167925,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"6deba879-8787-4853-a5f2-0d108a08eb2d"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785150167927,"data":{"title":"Use the ask_user_question tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1785150167928,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1785150167929,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1785150168452,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"reasoning-chunks","seq0":6,"time0":1785150168452,"data":{"turn":1,"step":1,"index":0,"dt":[87,26,1,0,0,0,38,0,0,0,0,1,12,27,0,27,0,0,1,25,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," ask","_user","_","question"," tool"," with"," specific"," parameters","."," Let"," me"," do"," exactly"," that","."]}} {"type":"assistant/chunk","seq":28,"time":1785150168775,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":29,"time0":1785150168776,"data":{"turn":1,"step":1,"index":1,"dt":[25,1,0,0,0,25,0,0,0,26,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,1,25,0,0,0,0,1,25,1,0,0,0,0,25,1,0,0,26,0,0,1,0,24,1,0,0,0,1,26,1,0,0,0,0,25,0,1,0,0,0,25,1,0,0,25,0,0,0,0,1,25,0,0,1,0,0,25,0,0,0,1,0,26,1,24],"id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","args":["","{","\"","questions","\"",": ","[","{\"","id","\":"," \"","color","\","," \"","question","\":"," \"","Which"," color"," do"," you"," prefer","?\","," \"","header","\":"," \"","Pick"," one","\","," \"","options","\":"," [","{\"","label","\":"," \"","Blue","\","," \"","description","\":"," \"","A"," cool"," recessive"," hue"," that"," reads"," as"," calm"," and"," trustworthy"," in"," long"," reading"," sessions"," and"," dense"," dash","boards",".\"","},"," {\"","label","\":"," \"","Green","\","," \"","description","\":"," \"","A"," rest","ful"," mid","-spect","rum"," hue"," with"," the"," highest"," perceived"," brightness",","," easiest"," on"," the"," eye"," over"," long"," sessions",".\"","}]","}]","}"]}} +{"type":"tool-call-chunks","seq0":29,"time0":1785150168776,"data":{"turn":1,"step":1,"index":1,"dt":[25,1,0,0,0,25,0,0,0,26,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,1,25,0,0,0,0,1,25,1,0,0,0,0,25,1,0,0,26,0,0,1,0,24,1,0,0,0,1,26,1,0,0,0,0,25,0,1,0,0,0,25,1,0,0,25,0,0,0,0,1,25,0,0,1,0,0,25,0,0,0,1,0,26,1,24],"id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","args":["","{","\"","questions","\"",": ","[","{\"","id","\":"," \"","color","\","," \"","question","\":"," \"","Which"," color"," do"," you"," prefer","?\","," \"","header","\":"," \"","Pick"," one","\", \"multi_select\": true,"," \"","options","\":"," [","{\"","label","\":"," \"","Blue","\","," \"","description","\":"," \"","A"," cool"," recessive"," hue"," that"," reads"," as"," calm"," and"," trustworthy"," in"," long"," reading"," sessions"," and"," dense"," dash","boards",".\"","},"," {\"","label","\":"," \"","Green","\","," \"","description","\":"," \"","A"," rest","ful"," mid","-spect","rum"," hue"," with"," the"," highest"," perceived"," brightness",","," easiest"," on"," the"," eye"," over"," long"," sessions",".\"","}]","}]","}"]}} {"type":"assistant/chunk","seq":127,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."}}}} -{"type":"assistant/chunk","seq":128,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}}} +{"type":"assistant/chunk","seq":128,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"multi_select\": true, \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}}} {"type":"assistant/chunk","seq":129,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}}}} {"type":"assistant/chunk","seq":130,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":131,"time":1785150169311,"data":{"turn":1,"step":1,"usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"cdb1676c-e781-41ee-8f28-a3595371d729"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"} -{"type":"tool/call","seq":132,"time":1785150169312,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}} -{"type":"tool/result","seq":133,"time":1785150169787,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Cijldc88LYmVPCXYUsRq1617"},"content":[{"type":"tool-result","toolCallId":"call_00_Cijldc88LYmVPCXYUsRq1617","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"]}]}"}],"isError":false}],"role":"user","id":"c69ef39f-6f62-439f-b3f8-e8d10fba572f"}},"sourceEventSeqs":[132],"surfaceOp":"append"} +{"type":"assistant/message","seq":131,"time":1785150169311,"data":{"turn":1,"step":1,"usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"multi_select\": true, \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"cdb1676c-e781-41ee-8f28-a3595371d729"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"} +{"type":"tool/call","seq":132,"time":1785150169312,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"multi_select\": true, \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}} +{"type":"tool/result","seq":133,"time":1785150169787,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Cijldc88LYmVPCXYUsRq1617"},"content":[{"type":"tool-result","toolCallId":"call_00_Cijldc88LYmVPCXYUsRq1617","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"],\"custom\":\"Include accessibility notes\"}]}"}],"isError":false}],"role":"user","id":"c69ef39f-6f62-439f-b3f8-e8d10fba572f"}},"sourceEventSeqs":[132],"surfaceOp":"append"} {"type":"step/end","seq":134,"time":1785150169790,"data":{"turn":1,"step":1}} {"type":"step/start","seq":135,"time":1785150169790,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":136,"time":1785150170605,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/apps/web/tests/snapshots/question-composer/ui.expected.md b/apps/web/tests/snapshots/question-composer/ui.expected.md index 894f84d9ba..c2ee767319 100644 --- a/apps/web/tests/snapshots/question-composer/ui.expected.md +++ b/apps/web/tests/snapshots/question-composer/ui.expected.md @@ -3,9 +3,9 @@ - heading "Which color do you prefer?" [level=2] - button "Dismiss all questions": - img - - radiogroup: - - radio "Blue": 1 Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards. - - radio "Green": 2 Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. + - group: + - checkbox "Blue": Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards. + - checkbox "Green": Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. - textbox "Type your answer" - button "Previous question" [disabled]: - img diff --git a/packages/client/ui-question/tests/question-composer.spec.tsx b/packages/client/ui-question/tests/question-composer.spec.tsx index 87eb275b7d..adc32fc86d 100644 --- a/packages/client/ui-question/tests/question-composer.spec.tsx +++ b/packages/client/ui-question/tests/question-composer.spec.tsx @@ -238,6 +238,11 @@ describe('QuestionComposer', () => { fireEvent.keyDown(custom, { key: 'Enter' }) fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' })) fireEvent.click(screen.getByRole('button', { name: '提交' })) + expect(respond).toHaveBeenNthCalledWith(1, answeredEnvelope('second', [ + { id: 'profile', selected: ['工程落地型 (Recommended)'] }, + { id: 'detail', selected: [], custom: 'x' }, + { id: 'signals', selected: ['系统设计'] }, + ])) expect(await screen.findByText('网络中断')).toBeTruthy() expect(screen.getByRole('button', { name: '提交' }).disabled).toBe(false) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 73685c1f0a..b23e4178bb 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -280,8 +280,10 @@ function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQues if (new Set(answer.selected).size !== answer.selected.length) return false const custom = answer.custom?.trim() if (custom !== undefined && custom === '') return false - if (custom !== undefined && answer.selected.length > 0 && question.multiSelect !== true) return false - if (question.multiSelect !== true && answer.selected.length > 1) return false + if (question.multiSelect !== true) { + if (custom !== undefined && answer.selected.length > 0) return false + if (answer.selected.length > 1) return false + } const labels = new Set(question.options?.map(option => option.label) ?? []) return answer.selected.every(label => labels.has(label)) }) diff --git a/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts b/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts index 7d019a520a..0c55e33ed7 100644 --- a/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts +++ b/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts @@ -141,6 +141,7 @@ describe('ask_user_question tool', () => { return { answers: [ { id: 'targets', selected: ['tests', 'docs'], custom: 'release notes' }, + { id: 'labels-only', selected: ['tests'] }, { id: 'notes', selected: [], custom: 'ship today' }, ], } @@ -159,6 +160,12 @@ describe('ask_user_question tool', () => { options: [{ label: 'tests' }, { label: 'docs' }], multi_select: true, }, + { + id: 'labels-only', + question: 'Which labels should I keep?', + options: [{ label: 'tests' }, { label: 'docs' }], + multi_select: true, + }, { id: 'notes', question: 'Any note?' }, ], }, @@ -169,12 +176,13 @@ describe('ask_user_question tool', () => { expect(result.value).toEqual({ answers: [ { id: 'targets', selected: ['tests', 'docs'], custom: 'release notes' }, + { id: 'labels-only', selected: ['tests'] }, { id: 'notes', selected: [], custom: 'ship today' }, ], }) expect(result.content).toEqual([{ type: 'text', - text: '{"answers":[{"id":"targets","selected":["tests","docs"],"custom":"release notes"},{"id":"notes","selected":[],"custom":"ship today"}]}', + text: '{"answers":[{"id":"targets","selected":["tests","docs"],"custom":"release notes"},{"id":"labels-only","selected":["tests"]},{"id":"notes","selected":[],"custom":"ship today"}]}', }]) }) diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index 548372998f..eedf9e945c 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/tui/README.md -README.md: c8eb81b2d76c1616647baba37692ed8cd42e89dc -README.zh.md: 680bb89f12cbbad871010ed025cfa6d4369bb0a3 +README.md: 3b1c67dceadfe18a8d72bedc6a321a3fa86a3c90 +README.zh.md: a87858833eeb9220c709748eb5bbee3ff132eb8f diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index c8eb81b2d7..3b1c67dcea 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -153,7 +153,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -When a consumer calls `ctx.userInteraction.ask()`, this provider presents each question in order and returns selected option labels, `custom` text, or both for a multi-select question. Abort, cancellation, or UI disposal becomes `Error: ask_user_question was interrupted before the user answered` through `dsh-tool-ask-user`. +When a consumer calls `ctx.userInteraction.ask()`, this provider presents each question in order and returns selected option labels, `custom` text, or both for a multi-select question. Pending custom text survives switching back to options and joins checked labels on a later options-mode submit. Abort, cancellation, or UI disposal becomes `Error: ask_user_question was interrupted before the user answered` through `dsh-tool-ask-user`. #### Token effect diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index 680bb89f12..a87858833e 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -153,7 +153,7 @@ Paths prefixed with @ are files explicitly referenced by the user. Use the read #### 模型看到的内容 -消费方调用 `ctx.userInteraction.ask()` 时,此提供方会按顺序显示各个问题,并返回选中选项标签、`custom` 文本,或为多选题同时返回两者。中止、取消或 UI dispose 会变为 `Error: ask_user_question was interrupted before the user answered`;该转换由 `dsh-tool-ask-user` 完成。 +消费方调用 `ctx.userInteraction.ask()` 时,此提供方会按顺序显示各个问题,并返回选中选项标签、`custom` 文本,或为多选题同时返回两者。切回选项后,待提交的自定义文本仍会保留,并在之后从选项模式提交时与已勾选的标签一同返回。中止、取消或 UI dispose 会变为 `Error: ask_user_question was interrupted before the user answered`;该转换由 `dsh-tool-ask-user` 完成。 #### Token 影响 diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts index 0dac957cf3..59ce8fd3d7 100644 --- a/packages/ui/tui/src/components/dialogs.ts +++ b/packages/ui/tui/src/components/dialogs.ts @@ -804,11 +804,12 @@ export class QuestionDialog implements Component, Focusable { const selected = this.question.multiSelect ? this.selectedOptionLabels() : [options[this.selectedIndex]?.label].filter((label): label is string => label !== undefined) - if (selected.length === 0) { + const custom = this.question.multiSelect ? this.input.getValue().trim() : '' + if (selected.length === 0 && custom === '') { this.error = 'Select at least one option, or press Tab for a custom answer.' return } - this.done({ selected }) + this.done({ selected, ...(custom === '' ? {} : { custom }) }) } else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') { this.mode = 'custom' this.error = '' @@ -854,7 +855,12 @@ export class QuestionDialog implements Component, Focusable { push('') if (this.mode === 'custom') { for (const line of this.input.render(innerWidth)) push(line) - push(this.palette.dim(this.options.length > 0 ? 'Enter submit • Esc options' : 'Enter submit • Esc cancel')) + const controls = [ + ...(this.options.length > 0 && this.question.multiSelect ? [`${this.selected.size} selected`] : []), + 'Enter submit', + this.options.length > 0 ? 'Esc options' : 'Esc cancel', + ] + push(this.palette.dim(controls.join(' • '))) } else { const options = this.options const start = Math.max(0, Math.min( diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 72d35dd4b2..b2dfcb9536 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -4697,12 +4697,29 @@ describe('TUI user-interaction dialogs', () => { result.terminal.send('\x1b[B') result.terminal.send(' ') result.terminal.send('\t') + await tick() + expect(result.terminal.output).toContain('2 selected • Enter submit • Esc options') result.terminal.send('Tests') result.terminal.send('\r') await expect(multi).resolves.toEqual({ answers: [{ id: 'targets', selected: ['Code', 'Docs'], custom: 'Tests' }], }) + const labelsOnly = result.ctx.userInteraction.ask({ + questions: [{ + id: 'labels-only', + question: 'Pick one target', + multiSelect: true, + options: [{ label: 'Code' }, { label: 'Docs' }], + }], + }) + await tick() + result.terminal.send(' ') + result.terminal.send('\r') + await expect(labelsOnly).resolves.toEqual({ + answers: [{ id: 'labels-only', selected: ['Code'] }], + }) + const custom = result.ctx.userInteraction.ask({ questions: [{ id: 'other', question: 'Choose or type', options: [{ label: 'Default' }] }], }) @@ -4744,7 +4761,6 @@ describe('TUI user-interaction dialogs', () => { options: [{ label: 'One', description: 'first' }, { label: 'Two' }], }], }) - const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) await tick() result.terminal.send('\x1b[A') result.terminal.send('\x1b[B') @@ -4760,11 +4776,17 @@ describe('TUI user-interaction dialogs', () => { }) result.terminal.send('c') await tick() + result.terminal.send('keep this') + await tick() + expect(result.terminal.output).toContain('0 selected • Enter submit • Esc options') result.terminal.send('\x1b') await tick() expect(result.terminal.output).toContain('Space toggle') - result.terminal.send('\x03') - await rejected + result.terminal.send(' ') + result.terminal.send('\r') + await expect(answer).resolves.toEqual({ + answers: [{ id: 'options', selected: ['One'], custom: 'keep this' }], + }) await dispose(result) }) From eb101230154e40dea237209e047759acf9d47cb0 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 03:57:57 -0700 Subject: [PATCH 10/52] test(web): simplify approval snapshot assertions --- apps/web/tests/built-boot.snapshot.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index d436d41866..7ca9b5fbbb 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -106,11 +106,10 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn // distinguishes a blocked running session from an ordinarily busy one. const waitingTitle = await within(tree).findByText('Fixture 历史会话') const waitingRow = waitingTitle.closest('[role="treeitem"]') - expect(waitingRow).not.toBeNull() if (waitingRow === null) throw new Error('fixture Session title must belong to a tree row') expect(waitingRow.querySelector('[data-state="warning"]')).not.toBeNull() expect(waitingRow.querySelector('[data-state="ongoing"]')).toBeNull() - expect(within(waitingRow).getByText('Waiting for approval')).not.toBeNull() + within(waitingRow).getByText('Waiting for approval') // Opening a session reaches chat content through the fixture transport. fireEvent.click(waitingTitle) From e1fe6696dfe01ee03a42dae529bc162286a1c58e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 19:52:46 +0800 Subject: [PATCH 11/52] test(web): refresh question composer disclosure --- .../tests/snapshots/question-composer/answered.expected.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index 7f7603eb8a..692e0a5968 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -11,7 +11,10 @@ - img - button "编辑": - img -- button "▸ 上下文注入" +- button "上下文注入": + - img + - img + - text: 上下文注入 - button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.": - img - img From e98cd522eef808f62f67dd21f656c523b654af69 Mon Sep 17 00:00:00 2001 From: kingwl Date: Fri, 31 Jul 2026 11:41:16 +0800 Subject: [PATCH 12/52] fix TUI diff context line accounting --- ...tui-diff-context-line-accounting.i18n.yaml | 6 ++ ...-07-31-tui-diff-context-line-accounting.md | 29 ++++++++++ ...-31-tui-diff-context-line-accounting.zh.md | 29 ++++++++++ packages/ui/tui/README.i18n.yaml | 4 +- packages/ui/tui/README.md | 2 +- packages/ui/tui/README.zh.md | 2 +- packages/ui/tui/package.json | 1 + packages/ui/tui/src/components/transcript.ts | 49 +++++++++++++--- .../advanced-cards-collapsed.expected.txt | 4 +- .../advanced-cards-expanded.expected.txt | 56 +++++++++---------- packages/ui/tui/tests/tui.spec.ts | 17 ++++-- pnpm-lock.yaml | 3 + 12 files changed, 153 insertions(+), 49 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.i18n.yaml new file mode 100644 index 0000000000..2d43863567 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md +2026-07-31-tui-diff-context-line-accounting.md: 71593022b56d9e675025f3a7a6d1e5e3edfa9b57 +2026-07-31-tui-diff-context-line-accounting.zh.md: df374c23b73fc5667cf733b4916d9d0d2ecb9198 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md new file mode 100644 index 0000000000..71593022b5 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md @@ -0,0 +1,29 @@ +# Agent Note: TUI diff context lines stay neutral + +Status: implemented + +English | [中文](2026-07-31-tui-diff-context-line-accounting.zh.md) + +## Problem + +Result-time filesystem diffs carry the applied change with three surrounding context lines in each `FileDiff.oldText` and `FileDiff.newText`. The TUI rendered every old-side row as removed and every new-side row as added, including the identical context present on both sides. A one-line edit therefore appeared as seven removals plus seven additions, and the footer repeated those inflated totals. + +## Decision + +The TUI compares each non-create `FileDiff.oldText` and `FileDiff.newText` at render time. Added and removed rows retain their green `+` and red `-` markers; equal context rows use the recessed body tone with a neutral two-space prefix. The footer sums only the rows classified as added or removed. A create (`oldText: null`) continues to classify every non-empty new-content row as added. + +This remains a consumer-side interpretation of the existing `FileDiff` contract. Filesystem tools continue to persist contextual before/after snippets, so other consumers keep their placement context and existing session logs replay with corrected TUI presentation. The TUI uses the same maintained `diff` package as `dsh-tool-fs` instead of introducing a second line-diff implementation. + +## Alternatives considered + +**Remove context from filesystem result metadata.** Rejected: contextual applied hunks are intentional producer output used by capable editors, and changing them would weaken every consumer while leaving old session logs misleading in the TUI. + +**Extend `FileDiff` with persisted per-line tags.** Rejected: the tags can be derived deterministically from the existing before/after pair; persisting them would widen the cross-package and session-log contract solely for one renderer. + +**Match equal lines by position without a diff algorithm.** Rejected: insertions and deletions shift subsequent context, so positional pairing would misclassify valid hunks. + +## Consequences + +TUI diff cards distinguish evidence-bearing context from the mutation itself, and their `+A -R` footer reports the actual line delta. Replaying an existing contextual diff gains the corrected rendering without a migration. Rendering performs one additional line comparison per non-create hunk; result-time hunks are already context-bounded, while create cards bypass the comparison. + +The focused TUI test covers neutral context and exact totals. The assembled `advanced-cards` terminal snapshots pin the neutral context style, semantic change colors, and `+1 -1` footer through collapsed and expanded card states. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md new file mode 100644 index 0000000000..df374c23b7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md @@ -0,0 +1,29 @@ +# Agent Note: TUI diff 上下文行保持中性 + +Status: implemented + +[English](2026-07-31-tui-diff-context-line-accounting.md) | 中文 + +## 问题 + +文件系统 diff 返回结果时,每个 `FileDiff.oldText` 和 `FileDiff.newText` 都会包含已应用的变更及其前后各 3 行上下文。TUI 将旧侧的每一行都渲染为删除行,将新侧的每一行都渲染为新增行,其中包括两侧相同的上下文。因此,一行编辑会显示为删除 7 行并新增 7 行,页脚还会重复这些虚高的合计值。 + +## 决策 + +对于每个不对应文件创建的 `FileDiff`,TUI 在渲染时比较 `FileDiff.oldText` 和 `FileDiff.newText`。新增行和删除行仍分别使用绿色 `+` 和红色 `-` 标记;相同的上下文行则使用弱化的正文色调,并带有由两个空格构成的中性前缀。页脚只汇总归类为新增或删除的行。创建操作(`oldText: null`)仍将新内容中的每个非空行归类为新增行。 + +该行为仍然只是消费方对现有 `FileDiff` 契约的解释。文件系统工具仍会持久化带上下文的变更前后片段,因此其他消费方仍能获得定位上下文,已有会话日志在回放时也会采用修正后的 TUI 呈现。TUI 与 `dsh-tool-fs` 共用同一个受维护的 `diff` 包(package),无需引入第二套逐行 diff 实现。 + +## 考虑过的替代方案 + +**从文件系统结果元数据中移除上下文。** 不予采纳:带上下文的已应用 hunk 是有意保留的生产方输出,供具备相应能力的编辑器使用;更改这些内容会让所有消费方丢失信息,同时旧会话日志在 TUI 中仍会产生误导。 + +**为 `FileDiff` 扩展持久化的逐行标签。** 不予采纳:这些标签可以根据现有的变更前后文本对确定性派生;仅为一个渲染器持久化标签,会扩大跨包契约和会话日志契约。 + +**不使用 diff 算法,按位置匹配相同行。** 不予采纳:插入和删除会使后续上下文发生位移,因此按位置配对会把有效 hunk 错误分类。 + +## 后果 + +TUI diff 卡片会区分用于佐证的上下文与变更本身,其 `+A -R` 页脚报告实际的行变更量。回放已有的上下文 diff 无需迁移即可获得修正后的渲染。渲染每个不对应文件创建的 hunk 时,会额外执行一次逐行比较;结果时刻的 hunk 本就受上下文范围限制,创建卡片则会跳过比较。 + +聚焦的 TUI 测试覆盖中性上下文和精确合计值。组装后的 `advanced-cards` 终端快照在卡片折叠和展开状态下固定了中性上下文样式、变更行的语义色彩,以及 `+1 -1` 页脚。 diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index e94be1a857..92bfb18e29 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/tui/README.md -README.md: 63c888b1d51c02fa85a8f0cc1617874debd87c4e -README.zh.md: ca5efc9ae26a9833d271991f73a21c607d8fb09d +README.md: b021789d660fd831c3fa0dad20d0bc174538eb57 +README.zh.md: b9cd7210932558a3a2feb0d5c1bfaf7e115703f6 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 63c888b1d5..b021789d66 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -83,7 +83,7 @@ Every SGR code the TUI emits lives in one table, `paletteSpec` in `components/th There is one role per visual meaning: `dim` is the single recessed tone and `accent` the single emphasis color, while `success` and `error` double as a diff's added and removed lines. Colors and attributes are separately typed, so `bold(accent(x))` compiles and `accent(error(x))` does not — SGR has no color stack, so nesting one color inside another silently drops the outer color at the inner one's close. Attributes occupy independent SGR groups and compose with any color in either order. Run `/palette` to see every role as your terminal renders it, with its SGR pair. -Grouped regions (user prompts, assistant replies, tool cards) are separated by a bold, underlined role header in the role color and blank-line spacing rather than a filled block or a per-line prefix, so a mouse drag-select copies the message text without any leading bar or indent; a tool card's status (pending, error, success) shows in its colored, underlined title glyph and title. Inside a tool card, the whole body — presenter title, a terminal `$` command and cwd, and the tool's own output — renders in one dim tone, so only the status-colored header carries color and the body reads as one recessed block instead of a run of competing shades; an injected-context card's prose is the same tone as its header. A diff card's `+`/`-` lines and a `[signal …]` marker stay colored, because there the color is the meaning rather than emphasis. The question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling. +Grouped regions (user prompts, assistant replies, tool cards) are separated by a bold, underlined role header in the role color and blank-line spacing rather than a filled block or a per-line prefix, so a mouse drag-select copies the message text without any leading bar or indent; a tool card's status (pending, error, success) shows in its colored, underlined title glyph and title. Inside a tool card, the whole body — presenter title, a terminal `$` command and cwd, and the tool's own output — renders in one dim tone, so only the status-colored header carries color and the body reads as one recessed block instead of a run of competing shades; an injected-context card's prose is the same tone as its header. A diff card colors and counts only added `+` and removed `-` lines; unchanged context stays dim and uncounted. A `[signal …]` marker remains colored because there the color is the meaning rather than emphasis. The question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling. ## Model Experience diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index ca5efc9ae2..b9cd721093 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -83,7 +83,7 @@ TUI 发出的所有 SGR 代码都集中在一个表中,即 `components/theme.t 每种视觉语义只对应一个角色:`dim` 是唯一的弱化色调,`accent` 是唯一的强调色,`success` 和 `error` 还分别充当 diff 的新增行与删除行。颜色和属性分属不同类型,因此 `bold(accent(x))` 可以通过编译,`accent(error(x))` 则不行——SGR 没有颜色栈;在一种颜色内嵌套另一种颜色时,内层颜色闭合时会静默丢弃外层颜色。各属性占用彼此独立的 SGR 组,可以按任一顺序与任何颜色组合。运行 `/palette` 可查看每个角色在你的终端上的实际渲染效果及其 SGR 码对。 -成组区域(用户提示词、assistant 回复、工具卡片)通过以角色色渲染的粗体带下划线角色标题和空行分隔,而非填充背景块或逐行前缀,因此用鼠标框选复制时不会带上任何左侧竖条或缩进;工具卡片的状态(进行中、错误、成功)由其彩色带下划线的标题字形与标题体现。在工具卡片内部,整个正文——presenter 标题、终端 `$` 命令与 cwd,以及工具自身的输出——统一以同一种暗色渲染,因此只有带状态色的表头携带颜色,正文读作一个整体弱化的区块,而不是一串互相竞争的色调;注入上下文卡片的正文与其表头也是同一种色调。diff 卡片的 `+`/`-` 行与 `[signal …]` 标记保留颜色,因为那里的颜色本身就是语义,而非强调。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。 +成组区域(用户提示词、assistant 回复、工具卡片)通过以角色色渲染的粗体带下划线角色标题和空行分隔,而非填充背景块或逐行前缀,因此用鼠标框选复制时不会带上任何左侧竖条或缩进;工具卡片的状态(进行中、错误、成功)由其彩色带下划线的标题字形与标题体现。在工具卡片内部,整个正文——presenter 标题、终端 `$` 命令与 cwd,以及工具自身的输出——统一以同一种暗色渲染,因此只有带状态色的表头携带颜色,正文读作一个整体弱化的区块,而不是一串互相竞争的色调;注入上下文卡片的正文与其表头也是同一种色调。diff 卡片只为新增的 `+` 行和删除的 `-` 行着色并计数;未变更的上下文保持暗色且不纳入计数。`[signal …]` 标记仍保留颜色,因为那里的颜色本身就是语义,而非强调。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。 ## 模型体验 diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json index c3506ea338..a68fe7968a 100644 --- a/packages/ui/tui/package.json +++ b/packages/ui/tui/package.json @@ -66,6 +66,7 @@ }, "dependencies": { "@earendil-works/pi-tui": "0.80.7", + "diff": "^9.0.0", "saxes": "6.0.0", "schemastery": "^3.18.0" }, diff --git a/packages/ui/tui/src/components/transcript.ts b/packages/ui/tui/src/components/transcript.ts index 774e982f81..5c8b9bf749 100644 --- a/packages/ui/tui/src/components/transcript.ts +++ b/packages/ui/tui/src/components/transcript.ts @@ -15,6 +15,7 @@ import { type Component, type MarkdownTheme, } from '@earendil-works/pi-tui' +import { diffLines as compareLines } from 'diff' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import type { JsonValue, SessionEvent, TodoItem } from '@deepseek-ai/dsh-session' @@ -52,16 +53,45 @@ function pretty(value: unknown): string { return displayText(serialized ?? String(value)) } -/** A file diff as colored `+`/`-` lines, optionally prefixed with its path. */ -function diffLines(diff: FileDiff, palette: Palette): string[] { +interface RenderedDiff { + lines: string[] + added: number + removed: number +} + +/** Split one diff change into display rows without counting its trailing line terminator. */ +function diffValueLines(value: string): string[] { + if (value === '') return [] + const safe = displayText(value) + return (safe.endsWith('\n') ? safe.slice(0, -1) : safe).split('\n') +} + +/** A file diff whose unchanged context stays neutral and does not affect change totals. */ +function renderDiff(diff: FileDiff, palette: Palette): RenderedDiff { // The card header is a fixed `Tool / ` frame that never names a file, so // each hunk always carries its own path header (no redundancy to suppress). const lines = [palette.bold(displayText(diff.path))] - if (diff.oldText !== null) { - for (const line of displayText(diff.oldText).split('\n')) lines.push(palette.error(`- ${line}`)) + let added = 0 + let removed = 0 + if (diff.oldText === null) { + const newLines = diffValueLines(diff.newText) + added = newLines.length + for (const line of newLines) lines.push(palette.success(`+ ${line}`)) + return { lines, added, removed } } - for (const line of displayText(diff.newText).split('\n')) lines.push(palette.success(`+ ${line}`)) - return lines + for (const change of compareLines(diff.oldText, diff.newText)) { + const changedLines = diffValueLines(change.value) + if (change.added) { + added += changedLines.length + for (const line of changedLines) lines.push(palette.success(`+ ${line}`)) + } else if (change.removed) { + removed += changedLines.length + for (const line of changedLines) lines.push(palette.error(`- ${line}`)) + } else { + for (const line of changedLines) lines.push(palette.dim(` ${line}`)) + } + } + return { lines, added, removed } } /** @@ -505,9 +535,10 @@ export class ToolCardComponent implements Component { let added = 0 let removed = 0 const hunks = view.diffs.flatMap((diff, index) => { - if (diff.oldText !== null) removed += displayText(diff.oldText).split('\n').length - added += displayText(diff.newText).split('\n').length - return [...index > 0 ? [''] : [], ...diffLines(diff, this.palette)] + const rendered = renderDiff(diff, this.palette) + added += rendered.added + removed += rendered.removed + return [...index > 0 ? [''] : [], ...rendered.lines] }) const files = view.diffs.length const footer = this.palette.dim(`└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}`) diff --git a/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt b/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt index 2a005383e1..62f69c641f 100644 --- a/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt +++ b/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt @@ -31,9 +31,9 @@ buffer style 0-10 bold 14| "- old line " style 0-9 fg=red -15| "… +3 lines (Ctrl+O to expand) " +15| "… +2 lines (Ctrl+O to expand) " style 0-28 dim -16| "└ +2 -2 · 1 file " +16| "└ +1 -1 · 1 file " style 0-15 dim 17| 18| "● Tool / subagent" diff --git a/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt b/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt index 6f9aa094f3..55479a6f34 100644 --- a/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt +++ b/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt @@ -1,7 +1,7 @@ -terminal 100x40 buffer=normal length=43 base=3 viewport=3 +terminal 100x40 buffer=normal length=42 base=2 viewport=2 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=7 viewportRow=39 bufferRow=42 +cursor hidden column=7 viewportRow=39 bufferRow=41 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -37,54 +37,52 @@ buffer style 0-10 bold 17| "- old line " style 0-9 fg=red -18| "- keep " - style 0-5 fg=red -19| "+ new line " +18| "+ new line " style 0-9 fg=green -20| "+ keep " - style 0-5 fg=green -21| "└ +2 -2 · 1 file " +19| " keep " + style 0-5 dim +20| "└ +1 -1 · 1 file " style 0-15 dim -22| -23| "● Tool / subagent" +21| +22| "● Tool / subagent" style 0-16 fg=green -24| "Delegate renderer audit " +23| "Delegate renderer audit " style 0-99 dim -25| "The renderer has explicit lifecycle ownership. " +24| "The renderer has explicit lifecycle ownership. " style 0-99 dim -26| -27| "● Tool / task_output" +25| +26| "● Tool / task_output" style 0-19 fg=green -28| "Read output from background task subagent-7 " +27| "Read output from background task subagent-7 " style 0-99 dim -29| " " -30| "console " +28| " " +29| "console " style 0-6 dim -31| " started background task bash-5 " +30| " started background task bash-5 " style 0-1 dim style 2-31 fg=cyan dim style 32-99 dim -32| " " -33| -34| "● Tool / skill" +31| " " +32| +33| "● Tool / skill" style 0-13 fg=green -35| "Load skill dsh-code-review " +34| "Load skill dsh-code-review " style 0-99 dim -36| "Loaded review instructions. " +35| "Loaded review instructions. " style 0-99 dim -37| "Model wait 0.0s " +36| "Model wait 0.0s " style 0-14 dim -38| -39| "Tool and context cards expanded. " +37| +38| "Tool and context cards expanded. " style 0-31 dim -40| -41| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" +39| +40| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" style 0-17 fg=bright-magenta bold style 18-31 dim style 34-50 dim style 53-57 dim style 60-69 dim -42| " dsh > " +41| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 79aa6f4f97..1b28fa65c5 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -4315,7 +4315,11 @@ describe('tool cards and surface replay', () => { presentCall: () => ({ card: 'diff', title: 'Edit src/only.ts', - diffs: [{ path: 'src/only.ts', oldText: 'old', newText: 'new' }], + diffs: [{ + path: 'src/only.ts', + oldText: 'my: my-MM\nne: ne-NP\nnl: nl-NL\nnb: no-NO\npa: pa-Guru-IN\npl: pl-PL\npt_pt: pt-PT', + newText: 'my: my-MM\nne: ne-NP\nnl: nl-NL\nnb: nb-NO\npa: pa-Guru-IN\npl: pl-PL\npt_pt: pt-PT', + }], }), }, generic: { @@ -4622,7 +4626,7 @@ describe('tool cards and surface replay', () => { }) it('names a single-file diff in the body once, under a fixed Tool header', async () => { - const result = await setup({ tools }) + const result = await setup({ tools, config: { maxToolOutputLines: 20 } }) appendUser(result.session, 'edit one file') appendAssistant(result.session, [ { type: 'text', text: 'Editing' }, @@ -4638,9 +4642,12 @@ describe('tool cards and surface replay', () => { expect(output).toContain('Tool / singleDiff') expect(output).not.toContain('Edit src/only.ts') expect(output.split('src/only.ts').length - 1).toBe(1) - expect(output).toContain('- old') - expect(output).toContain('+ new') - expect(output).toContain('· 1 file') + expect(output).toContain(' my: my-MM') + expect(output).not.toContain('- my: my-MM') + expect(output).not.toContain('+ my: my-MM') + expect(output).toContain('- nb: no-NO') + expect(output).toContain('+ nb: nb-NO') + expect(output).toContain('└ +1 -1 · 1 file') await dispose(result) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ca1ec67f6..300b2731a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5458,6 +5458,9 @@ importers: '@earendil-works/pi-tui': specifier: 0.80.7 version: 0.80.7(patch_hash=6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946) + diff: + specifier: ^9.0.0 + version: 9.0.0 saxes: specifier: 6.0.0 version: 6.0.0 From 81ff2894ca63c1474d68829a4df98bf8b2c4f488 Mon Sep 17 00:00:00 2001 From: kingwl Date: Fri, 31 Jul 2026 12:58:30 +0800 Subject: [PATCH 13/52] fix(tui): bound diff rendering work --- ...tui-diff-context-line-accounting.i18n.yaml | 4 +- ...-07-31-tui-diff-context-line-accounting.md | 10 +- ...-31-tui-diff-context-line-accounting.zh.md | 10 +- docs/config-catalog.md | 4 +- packages/ui/tui/README.i18n.yaml | 4 +- packages/ui/tui/README.md | 4 +- packages/ui/tui/README.zh.md | 4 +- packages/ui/tui/src/components/transcript.ts | 50 +++++++--- packages/ui/tui/src/config.ts | 7 ++ packages/ui/tui/src/index.ts | 11 ++- .../advanced-cards-collapsed.expected.txt | 24 +++-- .../advanced-cards-expanded.expected.txt | 37 ++++++-- packages/ui/tui/tests/tui.snapshot.ts | 27 +++++- packages/ui/tui/tests/tui.spec.ts | 95 +++++++++++++++++++ 14 files changed, 246 insertions(+), 45 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.i18n.yaml index 2d43863567..6cdec24e63 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md -2026-07-31-tui-diff-context-line-accounting.md: 71593022b56d9e675025f3a7a6d1e5e3edfa9b57 -2026-07-31-tui-diff-context-line-accounting.zh.md: df374c23b73fc5667cf733b4916d9d0d2ecb9198 +2026-07-31-tui-diff-context-line-accounting.md: d1bc72ea030abd46f809ca3e746e6043f717baeb +2026-07-31-tui-diff-context-line-accounting.zh.md: a2a1dcce1325bca68c92cb4f206bfa35671e9d86 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md index 71593022b5..d1bc72ea03 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md @@ -10,7 +10,9 @@ Result-time filesystem diffs carry the applied change with three surrounding con ## Decision -The TUI compares each non-create `FileDiff.oldText` and `FileDiff.newText` at render time. Added and removed rows retain their green `+` and red `-` markers; equal context rows use the recessed body tone with a neutral two-space prefix. The footer sums only the rows classified as added or removed. A create (`oldText: null`) continues to classify every non-empty new-content row as added. +The TUI compares each `FileDiff` whose old and new text are both available. Added and removed rows retain their green `+` and red `-` markers; equal context rows use the recessed body tone with a neutral two-space prefix. The footer sums only the rows classified as added or removed. `maxDiffEditLength` bounds the exact comparison by its combined added and removed line count; the default is 1000. Exceeding the bound renders the complete old side as removed and the complete new side as added, marks the footer approximate, and caches that result so redraws do not repeat the comparison. + +When `oldText` is `null`, the renderer cannot distinguish a create from a pending overwrite or an argument fallback whose prior text is unavailable. It therefore shows every non-empty new-side row as added, without claiming those rows were absent from an existing file. Empty new content renders no synthetic added row. This remains a consumer-side interpretation of the existing `FileDiff` contract. Filesystem tools continue to persist contextual before/after snippets, so other consumers keep their placement context and existing session logs replay with corrected TUI presentation. The TUI uses the same maintained `diff` package as `dsh-tool-fs` instead of introducing a second line-diff implementation. @@ -22,8 +24,10 @@ This remains a consumer-side interpretation of the existing `FileDiff` contract. **Match equal lines by position without a diff algorithm.** Rejected: insertions and deletions shift subsequent context, so positional pairing would misclassify valid hunks. +**Run every comparison to completion.** Rejected: pending tool views can contain unrestricted model-authored old and new strings, and an unbounded Myers comparison can block the synchronous terminal renderer. + ## Consequences -TUI diff cards distinguish evidence-bearing context from the mutation itself, and their `+A -R` footer reports the actual line delta. Replaying an existing contextual diff gains the corrected rendering without a migration. Rendering performs one additional line comparison per non-create hunk; result-time hunks are already context-bounded, while create cards bypass the comparison. +TUI diff cards distinguish evidence-bearing context from the mutation itself, and an exact `+A -R` footer reports the actual line delta. Replaying an existing contextual diff gains the corrected rendering without a migration. Result-time filesystem hunks are context-bounded; unrestricted pending views either complete within the configured edit-length budget or degrade to an explicitly approximate linear rendering. -The focused TUI test covers neutral context and exact totals. The assembled `advanced-cards` terminal snapshots pin the neutral context style, semantic change colors, and `+1 -1` footer through collapsed and expanded card states. +The focused TUI tests cover neutral context, exact totals, an empty create, bounded fallback, and cache reuse. The assembled `advanced-cards` terminal snapshots pin the neutral context style, semantic change colors, exact footer, and approximate fallback through collapsed and expanded card states. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md index df374c23b7..a2a1dcce13 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md @@ -10,7 +10,9 @@ Status: implemented ## 决策 -对于每个不对应文件创建的 `FileDiff`,TUI 在渲染时比较 `FileDiff.oldText` 和 `FileDiff.newText`。新增行和删除行仍分别使用绿色 `+` 和红色 `-` 标记;相同的上下文行则使用弱化的正文色调,并带有由两个空格构成的中性前缀。页脚只汇总归类为新增或删除的行。创建操作(`oldText: null`)仍将新内容中的每个非空行归类为新增行。 +TUI 会比较每个变更前后文本均可用的 `FileDiff`。新增行和删除行仍分别使用绿色 `+` 和红色 `-` 标记;相同的上下文行则使用弱化的正文色调,并带有由两个空格构成的中性前缀。页脚只汇总归类为新增或删除的行。`maxDiffEditLength` 以新增行与删除行的合计数为精确比较设置上限,默认值为 1000。超过上限时,TUI 会把完整旧侧渲染为删除内容、把完整新侧渲染为新增内容,将页脚标记为近似结果,并缓存该结果,避免后续重绘重复比较。 + +当 `oldText` 为 `null` 时,渲染器无法区分文件创建、待处理覆写,以及旧文本不可用的参数回退。因此,它会把新侧的每个非空行显示并计作新增行,但不会声称这些行原先不存在于已有文件中。新内容为空时,不会渲染虚构的新增行。 该行为仍然只是消费方对现有 `FileDiff` 契约的解释。文件系统工具仍会持久化带上下文的变更前后片段,因此其他消费方仍能获得定位上下文,已有会话日志在回放时也会采用修正后的 TUI 呈现。TUI 与 `dsh-tool-fs` 共用同一个受维护的 `diff` 包(package),无需引入第二套逐行 diff 实现。 @@ -22,8 +24,10 @@ Status: implemented **不使用 diff 算法,按位置匹配相同行。** 不予采纳:插入和删除会使后续上下文发生位移,因此按位置配对会把有效 hunk 错误分类。 +**让所有比较都运行至完成。** 不予采纳:待处理工具视图可能包含由模型生成且长度不受限制的新旧字符串,无界的 Myers 比较可能阻塞同步终端渲染器。 + ## 后果 -TUI diff 卡片会区分用于佐证的上下文与变更本身,其 `+A -R` 页脚报告实际的行变更量。回放已有的上下文 diff 无需迁移即可获得修正后的渲染。渲染每个不对应文件创建的 hunk 时,会额外执行一次逐行比较;结果时刻的 hunk 本就受上下文范围限制,创建卡片则会跳过比较。 +TUI diff 卡片会区分用于佐证的上下文与变更本身,精确的 `+A -R` 页脚会报告实际的行变更量。回放已有的上下文 diff 无需迁移即可获得修正后的渲染。结果时刻的文件系统 hunk 受上下文范围限制;不受限制的待处理视图要么在配置的编辑长度预算内完成比较,要么降级为明确标注为近似结果的线性渲染。 -聚焦的 TUI 测试覆盖中性上下文和精确合计值。组装后的 `advanced-cards` 终端快照在卡片折叠和展开状态下固定了中性上下文样式、变更行的语义色彩,以及 `+1 -1` 页脚。 +聚焦的 TUI 测试覆盖中性上下文、精确合计值、空文件创建、有界回退和缓存复用。组装后的 `advanced-cards` 终端快照在卡片折叠和展开状态下固定了中性上下文样式、变更行的语义色彩、精确结果页脚和近似回退。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 14446b91d8..0c76ccb449 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2015,6 +2015,8 @@ export interface TuiConfig { showReasoning?: boolean /** Maximum tool-card body lines retained in its collapsed head/tail preview. */ maxToolOutputLines?: number + /** Maximum added and removed lines explored while deriving an exact line diff. */ + maxDiffEditLength?: number /** Maximum options visible at once in a user-question panel. */ maxQuestionOptions?: number /** Maximum models visible at once in the model selector. */ @@ -2060,7 +2062,7 @@ export interface TuiThemeConfig { } ``` -Source: [`packages/ui/tui/src/config.ts:117`](../packages/ui/tui/src/config.ts) +Source: [`packages/ui/tui/src/config.ts:121`](../packages/ui/tui/src/config.ts) ## `@deepseek-ai/dsh-typert-loader` diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index 92bfb18e29..95332df16e 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/tui/README.md -README.md: b021789d660fd831c3fa0dad20d0bc174538eb57 -README.zh.md: b9cd7210932558a3a2feb0d5c1bfaf7e115703f6 +README.md: 837e072ec63752d5f0f1b93bcff16871d32ad615 +README.zh.md: 15f3ad49f2d5b7cdd438532d7443b066a1eac87d diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index b021789d66..837e072ec6 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -50,6 +50,7 @@ A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY | `sessionId` | `main` | Exact shared agent/session identity driven by the terminal | | `showReasoning` | `true` | Render reasoning blocks | | `maxToolOutputLines` | `6` | Output lines retained across a collapsed tool card's head/tail preview | +| `maxDiffEditLength` | `1000` | Maximum added and removed lines explored for an exact diff before whole-side fallback | | `maxQuestionOptions` | `8` | Visible options in a question panel | | `maxModelOptions` | `8` | Visible models in the model selector | | `maxResumeOptions` | `8` | Visible sessions in the resume selector | @@ -72,6 +73,7 @@ A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY sessionId: main-session-123 showReasoning: true maxToolOutputLines: 6 + maxDiffEditLength: 1000 fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist'] ``` @@ -83,7 +85,7 @@ Every SGR code the TUI emits lives in one table, `paletteSpec` in `components/th There is one role per visual meaning: `dim` is the single recessed tone and `accent` the single emphasis color, while `success` and `error` double as a diff's added and removed lines. Colors and attributes are separately typed, so `bold(accent(x))` compiles and `accent(error(x))` does not — SGR has no color stack, so nesting one color inside another silently drops the outer color at the inner one's close. Attributes occupy independent SGR groups and compose with any color in either order. Run `/palette` to see every role as your terminal renders it, with its SGR pair. -Grouped regions (user prompts, assistant replies, tool cards) are separated by a bold, underlined role header in the role color and blank-line spacing rather than a filled block or a per-line prefix, so a mouse drag-select copies the message text without any leading bar or indent; a tool card's status (pending, error, success) shows in its colored, underlined title glyph and title. Inside a tool card, the whole body — presenter title, a terminal `$` command and cwd, and the tool's own output — renders in one dim tone, so only the status-colored header carries color and the body reads as one recessed block instead of a run of competing shades; an injected-context card's prose is the same tone as its header. A diff card colors and counts only added `+` and removed `-` lines; unchanged context stays dim and uncounted. A `[signal …]` marker remains colored because there the color is the meaning rather than emphasis. The question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling. +Grouped regions (user prompts, assistant replies, tool cards) are separated by a bold, underlined role header in the role color and blank-line spacing rather than a filled block or a per-line prefix, so a mouse drag-select copies the message text without any leading bar or indent; a tool card's status (pending, error, success) shows in its colored, underlined title glyph and title. Inside a tool card, the whole body — presenter title, a terminal `$` command and cwd, and the tool's own output — renders in one dim tone, so only the status-colored header carries color and the body reads as one recessed block instead of a run of competing shades; an injected-context card's prose is the same tone as its header. A diff card with both sides available colors and counts exact added `+` and removed `-` lines, while unchanged context stays dim and uncounted. If exact comparison exceeds `maxDiffEditLength`, the card renders each old-side row as removed and each new-side row as added, marks the footer approximate, and caches that fallback for later redraws. When `oldText` is unavailable, including pending writes and replay fallbacks as well as creates, every non-empty new-side row is shown and counted as added; that count does not prove the rows were absent from an existing file. Empty new content produces no synthetic `+ ` row. A `[signal …]` marker remains colored because there the color is the meaning rather than emphasis. The question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling. ## Model Experience diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index b9cd721093..15f3ad49f2 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -50,6 +50,7 @@ Footer 将会话报告的用量汇总为 `↑`;任 | `sessionId` | `main` | 由终端驱动的精确共享 agent/会话身份 | | `showReasoning` | `true` | 渲染 reasoning 块 | | `maxToolOutputLines` | `6` | 折叠工具卡片的头尾预览所保留的输出行数 | +| `maxDiffEditLength` | `1000` | 回退到整侧展示前,精确 diff 最多探索的新增与删除行总数 | | `maxQuestionOptions` | `8` | 问题面板中可见的选项数 | | `maxModelOptions` | `8` | 模型选择器中可见的模型数 | | `maxResumeOptions` | `8` | 恢复选择器中可见的会话数 | @@ -72,6 +73,7 @@ Footer 将会话报告的用量汇总为 `↑`;任 sessionId: main-session-123 showReasoning: true maxToolOutputLines: 6 + maxDiffEditLength: 1000 fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist'] ``` @@ -83,7 +85,7 @@ TUI 发出的所有 SGR 代码都集中在一个表中,即 `components/theme.t 每种视觉语义只对应一个角色:`dim` 是唯一的弱化色调,`accent` 是唯一的强调色,`success` 和 `error` 还分别充当 diff 的新增行与删除行。颜色和属性分属不同类型,因此 `bold(accent(x))` 可以通过编译,`accent(error(x))` 则不行——SGR 没有颜色栈;在一种颜色内嵌套另一种颜色时,内层颜色闭合时会静默丢弃外层颜色。各属性占用彼此独立的 SGR 组,可以按任一顺序与任何颜色组合。运行 `/palette` 可查看每个角色在你的终端上的实际渲染效果及其 SGR 码对。 -成组区域(用户提示词、assistant 回复、工具卡片)通过以角色色渲染的粗体带下划线角色标题和空行分隔,而非填充背景块或逐行前缀,因此用鼠标框选复制时不会带上任何左侧竖条或缩进;工具卡片的状态(进行中、错误、成功)由其彩色带下划线的标题字形与标题体现。在工具卡片内部,整个正文——presenter 标题、终端 `$` 命令与 cwd,以及工具自身的输出——统一以同一种暗色渲染,因此只有带状态色的表头携带颜色,正文读作一个整体弱化的区块,而不是一串互相竞争的色调;注入上下文卡片的正文与其表头也是同一种色调。diff 卡片只为新增的 `+` 行和删除的 `-` 行着色并计数;未变更的上下文保持暗色且不纳入计数。`[signal …]` 标记仍保留颜色,因为那里的颜色本身就是语义,而非强调。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。 +成组区域(用户提示词、assistant 回复、工具卡片)通过以角色色渲染的粗体带下划线角色标题和空行分隔,而非填充背景块或逐行前缀,因此用鼠标框选复制时不会带上任何左侧竖条或缩进;工具卡片的状态(进行中、错误、成功)由其彩色带下划线的标题字形与标题体现。在工具卡片内部,整个正文——presenter 标题、终端 `$` 命令与 cwd,以及工具自身的输出——统一以同一种暗色渲染,因此只有带状态色的表头携带颜色,正文读作一个整体弱化的区块,而不是一串互相竞争的色调;注入上下文卡片的正文与其表头也是同一种色调。当前后两侧文本均可用时,diff 卡片会为精确识别出的新增 `+` 行和删除 `-` 行着色并计数;未变更的上下文保持暗色且不纳入计数。如果精确比较超出 `maxDiffEditLength`,卡片会把旧侧每一行渲染为删除行、把新侧每一行渲染为新增行,将页脚标记为近似结果,并缓存该回退结果供后续重绘使用。当 `oldText` 不可用时(包括待处理写入、回放回退以及文件创建),新侧的每个非空行都会显示并计作新增行;该计数不能证明这些行原先不存在于已有文件中。新内容为空时,不会补出虚构的 `+ ` 行。`[signal …]` 标记仍保留颜色,因为那里的颜色本身就是语义,而非强调。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。 ## 模型体验 diff --git a/packages/ui/tui/src/components/transcript.ts b/packages/ui/tui/src/components/transcript.ts index 5c8b9bf749..1bdc73e250 100644 --- a/packages/ui/tui/src/components/transcript.ts +++ b/packages/ui/tui/src/components/transcript.ts @@ -57,6 +57,7 @@ interface RenderedDiff { lines: string[] added: number removed: number + approximate: boolean } /** Split one diff change into display rows without counting its trailing line terminator. */ @@ -66,8 +67,12 @@ function diffValueLines(value: string): string[] { return (safe.endsWith('\n') ? safe.slice(0, -1) : safe).split('\n') } -/** A file diff whose unchanged context stays neutral and does not affect change totals. */ -function renderDiff(diff: FileDiff, palette: Palette): RenderedDiff { +/** + * A file diff whose unchanged context stays neutral and does not affect exact + * change totals. Comparisons beyond the edit-distance budget fall back to + * whole-side rendering so a model-authored pending edit cannot stall the TUI. + */ +function renderDiff(diff: FileDiff, maxDiffEditLength: number, palette: Palette): RenderedDiff { // The card header is a fixed `Tool / ` frame that never names a file, so // each hunk always carries its own path header (no redundancy to suppress). const lines = [palette.bold(displayText(diff.path))] @@ -77,9 +82,20 @@ function renderDiff(diff: FileDiff, palette: Palette): RenderedDiff { const newLines = diffValueLines(diff.newText) added = newLines.length for (const line of newLines) lines.push(palette.success(`+ ${line}`)) - return { lines, added, removed } + return { lines, added, removed, approximate: false } } - for (const change of compareLines(diff.oldText, diff.newText)) { + const changes = compareLines(diff.oldText, diff.newText, { maxEditLength: maxDiffEditLength }) + if (changes === undefined) { + const oldLines = diffValueLines(diff.oldText) + const newLines = diffValueLines(diff.newText) + lines.push(palette.dim(`[exact line diff omitted: >${maxDiffEditLength} changed lines]`)) + removed = oldLines.length + added = newLines.length + for (const line of oldLines) lines.push(palette.error(`- ${line}`)) + for (const line of newLines) lines.push(palette.success(`+ ${line}`)) + return { lines, added, removed, approximate: true } + } + for (const change of changes) { const changedLines = diffValueLines(change.value) if (change.added) { added += changedLines.length @@ -91,7 +107,7 @@ function renderDiff(diff: FileDiff, palette: Palette): RenderedDiff { for (const line of changedLines) lines.push(palette.dim(` ${line}`)) } } - return { lines, added, removed } + return { lines, added, removed, approximate: false } } /** @@ -354,12 +370,14 @@ export class ToolCardComponent implements Component { private visibility: ToolCardVisibility = 'collapsed' private callView: ToolCallView private resultView: ToolResultView | undefined + private diffBodyCache: { view: ToolCallView | ToolResultView; body: CardBody } | undefined constructor( private readonly name: string, private readonly parsed: ParsedArguments, private readonly definition: ToolDefinition | undefined, private readonly maxOutputLines: number, + private readonly maxDiffEditLength: number, private readonly palette: Palette, private readonly mdTheme: MarkdownTheme, ) { @@ -530,21 +548,27 @@ export class ToolCardComponent implements Component { return { prelude: prelude.filter(Boolean), lines: lines.filter(Boolean) } } if (view.card === 'diff') { + if (this.diffBodyCache?.view === view) return this.diffBodyCache.body // The header no longer names the file, so each diff keeps its own path // header. A trailing footer summarizes the change (`+A -R · N file(s)`). - let added = 0 - let removed = 0 - const hunks = view.diffs.flatMap((diff, index) => { - const rendered = renderDiff(diff, this.palette) - added += rendered.added - removed += rendered.removed + const renderedDiffs = view.diffs.map(diff => + renderDiff(diff, this.maxDiffEditLength, this.palette), + ) + const added = renderedDiffs.reduce((total, rendered) => total + rendered.added, 0) + const removed = renderedDiffs.reduce((total, rendered) => total + rendered.removed, 0) + const approximate = renderedDiffs.some(rendered => rendered.approximate) + const hunks = renderedDiffs.flatMap((rendered, index) => { return [...index > 0 ? [''] : [], ...rendered.lines] }) const files = view.diffs.length - const footer = this.palette.dim(`└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}`) + const footer = this.palette.dim( + `└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}${approximate ? ' · approximate' : ''}`, + ) // A diff's own `+`/`-` colors carry its meaning, so it renders verbatim // rather than under the dim result-output color. - return { prelude: [...hunks, footer], lines: [] } + const body = { prelude: [...hunks, footer], lines: [] } + this.diffBodyCache = { view, body } + return body } // The web card carries no `content` copy, so a `web` result view falls back // to the raw result content here (`view.card === 'generic'` narrows the diff --git a/packages/ui/tui/src/config.ts b/packages/ui/tui/src/config.ts index def548861f..43c8404fee 100644 --- a/packages/ui/tui/src/config.ts +++ b/packages/ui/tui/src/config.ts @@ -34,6 +34,8 @@ export interface TuiConfig { showReasoning?: boolean /** Maximum tool-card body lines retained in its collapsed head/tail preview. */ maxToolOutputLines?: number + /** Maximum added and removed lines explored while deriving an exact line diff. */ + maxDiffEditLength?: number /** Maximum options visible at once in a user-question panel. */ maxQuestionOptions?: number /** Maximum models visible at once in the model selector. */ @@ -64,6 +66,7 @@ export interface TuiConfig { const showReasoningSchema = z.boolean().default(true) const maxToolOutputLinesSchema = z.number().step(1).min(1).default(6) +const maxDiffEditLengthSchema = z.number().step(1).min(1).default(1000) const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8) const maxModelOptionsSchema = z.number().step(1).min(1).default(8) const maxResumeOptionsSchema = z.number().step(1).min(1).default(8) @@ -95,6 +98,7 @@ const titleSchema = z.string().default('DeepSeek Harness') const tuiConfigSchemaFields = { showReasoning: showReasoningSchema, maxToolOutputLines: maxToolOutputLinesSchema, + maxDiffEditLength: maxDiffEditLengthSchema, maxQuestionOptions: maxQuestionOptionsSchema, maxModelOptions: maxModelOptionsSchema, maxResumeOptions: maxResumeOptionsSchema, @@ -135,6 +139,7 @@ export const Config: z = z.object({ initialSkill: z.string(), showReasoning: tuiConfigSchemaFields.showReasoning, maxToolOutputLines: tuiConfigSchemaFields.maxToolOutputLines, + maxDiffEditLength: tuiConfigSchemaFields.maxDiffEditLength, maxQuestionOptions: tuiConfigSchemaFields.maxQuestionOptions, maxModelOptions: tuiConfigSchemaFields.maxModelOptions, maxResumeOptions: tuiConfigSchemaFields.maxResumeOptions, @@ -164,6 +169,7 @@ export interface ResolvedTuiThemeConfig { export interface ResolvedTuiConfig { showReasoning: boolean maxToolOutputLines: number + maxDiffEditLength: number maxQuestionOptions: number maxModelOptions: number maxResumeOptions: number @@ -189,6 +195,7 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf return { showReasoning: config?.showReasoning ?? true, maxToolOutputLines: config?.maxToolOutputLines ?? 6, + maxDiffEditLength: config?.maxDiffEditLength ?? 1000, maxQuestionOptions: config?.maxQuestionOptions ?? 8, maxModelOptions: config?.maxModelOptions ?? 8, maxResumeOptions: config?.maxResumeOptions ?? 8, diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index a1250bb5b3..9745f1f30f 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -605,6 +605,7 @@ export function createTuiChat( parsed, ctx.tools.get(event.data.name, agent), resolved.maxToolOutputLines, + resolved.maxDiffEditLength, palette, mdTheme, ) @@ -748,7 +749,15 @@ export function createTuiChat( const callId = event.data.message.source.callId let card = toolCards.get(callId) if (card === undefined) { - card = new ToolCardComponent('tool', { value: {}, valid: true }, undefined, resolved.maxToolOutputLines, palette, mdTheme) + card = new ToolCardComponent( + 'tool', + { value: {}, valid: true }, + undefined, + resolved.maxToolOutputLines, + resolved.maxDiffEditLength, + palette, + mdTheme, + ) card.setVisibility(toolsVisibility) chat.addChild(card) allToolCards.add(card) diff --git a/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt b/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt index 62f69c641f..20ec3ab324 100644 --- a/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt +++ b/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt @@ -1,7 +1,7 @@ -terminal 100x40 buffer=normal length=40 base=0 viewport=0 +terminal 100x40 buffer=normal length=41 base=1 viewport=1 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=7 viewportRow=34 bufferRow=34 +cursor hidden column=7 viewportRow=39 bufferRow=40 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -58,17 +58,27 @@ buffer style 0-99 dim 30| "Loaded review instructions. " style 0-99 dim -31| "Model wait 0.0s " +31| +32| "● Tool / large_edit" + style 0-18 fg=green +33| "src/large.ts " + style 0-11 bold +34| "[exact line diff omitted: >2 changed lines] " + style 0-42 dim +35| "… +6 lines (Ctrl+O to expand) " + style 0-28 dim +36| "└ +3 -3 · 1 file · approximate " + style 0-29 dim +37| "Model wait 0.0s " style 0-14 dim -32| -33| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" +38| +39| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" style 0-17 fg=bright-magenta bold style 18-31 dim style 34-50 dim style 53-57 dim style 60-69 dim -34| " dsh > " +40| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse -35-39| diff --git a/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt b/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt index 55479a6f34..7752484db7 100644 --- a/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt +++ b/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt @@ -1,7 +1,7 @@ -terminal 100x40 buffer=normal length=42 base=2 viewport=2 +terminal 100x40 buffer=normal length=53 base=13 viewport=13 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=7 viewportRow=39 bufferRow=41 +cursor hidden column=7 viewportRow=39 bufferRow=52 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -70,19 +70,40 @@ buffer style 0-99 dim 35| "Loaded review instructions. " style 0-99 dim -36| "Model wait 0.0s " +36| +37| "● Tool / large_edit" + style 0-18 fg=green +38| "src/large.ts " + style 0-11 bold +39| "[exact line diff omitted: >2 changed lines] " + style 0-42 dim +40| "- old one " + style 0-8 fg=red +41| "- old two " + style 0-8 fg=red +42| "- old three " + style 0-10 fg=red +43| "+ new one " + style 0-8 fg=green +44| "+ new two " + style 0-8 fg=green +45| "+ new three " + style 0-10 fg=green +46| "└ +3 -3 · 1 file · approximate " + style 0-29 dim +47| "Model wait 0.0s " style 0-14 dim -37| -38| "Tool and context cards expanded. " +48| +49| "Tool and context cards expanded. " style 0-31 dim -39| -40| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" +50| +51| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" style 0-17 fg=bright-magenta bold style 18-31 dim style 34-50 dim style 53-57 dim style 60-69 dim -41| " dsh > " +52| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 18f0a9a793..db72a7290a 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -269,13 +269,32 @@ const ADVANCED_CARD_TOOLS: Record = { edit: visualTool( 'edit', () => ({ card: 'diff', title: 'Edit src/view.ts', diffs: [{ path: 'src/view.ts', oldText: 'old line', newText: 'new line' }] }), - // The real edit/write tools produce exactly one diff whose path the title - // already names, so the card omits the redundant per-file header. + // The fixed tool header never names a path, so the hunk retains its path. (): ToolResultView => ({ card: 'diff', diffs: [{ path: 'src/view.ts', oldText: 'old line\nkeep', newText: 'new line\nkeep' }], }), ), + large_edit: visualTool( + 'large_edit', + () => ({ + card: 'diff', + title: 'Edit src/large.ts', + diffs: [{ + path: 'src/large.ts', + oldText: 'old one\nold two\nold three', + newText: 'new one\nnew two\nnew three', + }], + }), + (): ToolResultView => ({ + card: 'diff', + diffs: [{ + path: 'src/large.ts', + oldText: 'old one\nold two\nold three', + newText: 'new one\nnew two\nnew three', + }], + }), + ), subagent: visualTool('subagent', args => ({ card: 'generic', title: 'Delegate renderer audit', @@ -585,7 +604,7 @@ describe('TUI terminal-state snapshots', () => { it('pins terminal, diff, subagent, task, skill, collapsed, and expanded cards', async () => { const harness = await setupSnapshot({ tools: ADVANCED_CARD_TOOLS, - config: { maxToolOutputLines: 3 }, + config: { maxToolOutputLines: 3, maxDiffEditLength: 2 }, }, { columns: 100, rows: 40 }) const calls = [ { id: 'advanced-1', name: 'bash', arguments: { command: 'pnpm run test:coverage' } }, @@ -593,6 +612,7 @@ describe('TUI terminal-state snapshots', () => { { id: 'advanced-3', name: 'subagent', arguments: { prompt: 'Review renderer ownership and report only gaps.' } }, { id: 'advanced-4', name: 'task_output', arguments: { task_id: 'subagent-7', wait: true } }, { id: 'advanced-5', name: 'skill', arguments: { name: 'dsh-code-review' } }, + { id: 'advanced-6', name: 'large_edit', arguments: { file_path: 'src/large.ts' } }, ] await renderAfter(harness, () => { appendToolCalls(harness.session, calls) @@ -601,6 +621,7 @@ describe('TUI terminal-state snapshots', () => { appendToolResult(harness.session, 'advanced-3', [{ type: 'text', text: 'The renderer has explicit lifecycle ownership.' }]) appendToolResult(harness.session, 'advanced-4', [{ type: 'text', text: 'audit complete\n[status: completed]' }]) appendToolResult(harness.session, 'advanced-5', [{ type: 'text', text: 'Loaded review instructions.' }]) + appendToolResult(harness.session, 'advanced-6', [{ type: 'text', text: 'large edit complete' }]) }) await checkpoint('advanced-cards-collapsed', harness.terminal, { includeScrollback: true }) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 1b28fa65c5..213169a89e 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -178,6 +178,7 @@ describe('TUI config', () => { expect(resolveTuiConfig(undefined)).toEqual({ showReasoning: true, maxToolOutputLines: 6, + maxDiffEditLength: 1000, maxQuestionOptions: 8, maxModelOptions: 8, maxResumeOptions: 8, @@ -202,6 +203,7 @@ describe('TUI config', () => { expect(resolveTuiConfig({ showReasoning: false, maxToolOutputLines: 2, + maxDiffEditLength: 12, maxQuestionOptions: 3, maxModelOptions: 4, maxResumeOptions: 5, @@ -218,6 +220,7 @@ describe('TUI config', () => { })).toEqual({ showReasoning: false, maxToolOutputLines: 2, + maxDiffEditLength: 12, maxQuestionOptions: 3, maxModelOptions: 4, maxResumeOptions: 5, @@ -4651,6 +4654,98 @@ describe('tool cards and surface replay', () => { await dispose(result) }) + it('renders an empty create without a synthetic added row', async () => { + const emptyCreate: Record = { + emptyCreate: { + name: 'emptyCreate', + description: '', + parameters: {}, + output: UNUSED_TOOL_OUTPUT, + execute: async () => [], + presentCall: () => ({ + card: 'diff', + title: 'Write empty.txt', + diffs: [{ path: 'empty.txt', oldText: null, newText: '' }], + }), + }, + } + const result = await setup({ + tools: emptyCreate, + config: { maxToolOutputLines: 20, theme: { color: false } }, + }) + appendAssistant(result.session, [ + { type: 'tool-call', id: 'empty-create' as never, name: 'emptyCreate', arguments: '{}' }, + ]) + result.session.append('tool/call', { + turn: 1, + step: 1, + callId: 'empty-create' as never, + name: 'emptyCreate', + arguments: '{}', + }) + await tick() + const rows = result.terminal.output.split('\n').map(row => row.trim()) + expect(result.terminal.output).toContain('empty.txt') + expect(result.terminal.output).toContain('└ +0 -0 · 1 file') + expect(rows).not.toContain('+') + await dispose(result) + }) + + it('bounds and caches exact diff comparison before whole-side fallback', async () => { + let oldTextReads = 0 + const boundedDiff = { + path: 'bounded.txt', + get oldText() { + oldTextReads += 1 + return 'old one\nold two' + }, + newText: 'new one\nnew two', + } + const bounded: Record = { + bounded: { + name: 'bounded', + description: '', + parameters: {}, + output: UNUSED_TOOL_OUTPUT, + execute: async () => [], + presentCall: () => ({ + card: 'diff', + title: 'Edit bounded.txt', + diffs: [boundedDiff], + }), + }, + } + const result = await setup({ + tools: bounded, + config: { + maxToolOutputLines: 20, + maxDiffEditLength: 1, + theme: { color: false }, + }, + }) + appendAssistant(result.session, [ + { type: 'tool-call', id: 'bounded-diff' as never, name: 'bounded', arguments: '{}' }, + ]) + result.session.append('tool/call', { + turn: 1, + step: 1, + callId: 'bounded-diff' as never, + name: 'bounded', + arguments: '{}', + }) + await tick() + expect(result.terminal.output).toContain('[exact line diff omitted: >1 changed lines]') + expect(result.terminal.output).toContain('- old one') + expect(result.terminal.output).toContain('+ new one') + expect(result.terminal.output).toContain('└ +2 -2 · 1 file · approximate') + const readsAfterFirstRender = oldTextReads + expect(readsAfterFirstRender).toBeGreaterThan(0) + result.terminal.resize(87) + await tick() + expect(oldTextReads).toBe(readsAfterFirstRender) + await dispose(result) + }) + it('drops blank rows from a terminal card result that the dim styling wraps', async () => { const blankRowTools: Record = { trailing: { From 8d3635315738ac45d91fce35a32ab90e2687c090 Mon Sep 17 00:00:00 2001 From: kingwl Date: Fri, 31 Jul 2026 12:58:46 +0800 Subject: [PATCH 14/52] docs: archive superseded TUI path note --- .../2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml | 4 ++-- .../bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md | 1 + .../2026-07-27-tui-diff-card-redundant-path-header.zh.md | 1 + .agents/notes/archived/manifest.json | 3 +++ 4 files changed, 7 insertions(+), 2 deletions(-) rename .agents/notes/{implemented => archived}/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml (66%) rename .agents/notes/{implemented => archived}/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md (99%) rename .agents/notes/{implemented => archived}/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md (99%) diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml b/.agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml similarity index 66% rename from .agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml rename to .agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml index a8472075e4..635e3fca62 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml +++ b/.agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md -2026-07-27-tui-diff-card-redundant-path-header.md: 708e543ff079828b4929d2a50ac697a9c846608a -2026-07-27-tui-diff-card-redundant-path-header.zh.md: 863868ae707f37689bbc202267c5470d8c3163e9 +2026-07-27-tui-diff-card-redundant-path-header.md: 608a11892a20d020087180175eff847021dc0554 +2026-07-27-tui-diff-card-redundant-path-header.zh.md: bf7f1c1eeb994f9940b5f7dfb7db72d422293bd4 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md b/.agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md rename to .agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md index 708e543ff0..608a11892a 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md +++ b/.agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md @@ -1,6 +1,7 @@ # Agent Note: TUI diff card dropped the duplicated file path Status: implemented +Archived: 2026-07-31 English | [中文](2026-07-27-tui-diff-card-redundant-path-header.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md b/.agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md rename to .agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md index 863868ae70..bf7f1c1eeb 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md +++ b/.agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md @@ -1,6 +1,7 @@ # Agent Note: TUI diff 卡片重复打印文件路径 Status: implemented +Archived: 2026-07-31 [English](2026-07-27-tui-diff-card-redundant-path-header.md) | 中文 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index e46d7c34cd..1ed77225ae 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -46,6 +46,9 @@ "bug-fix/2026-07-26-intent-draft-same-tick-echo.i18n.yaml": "sha256:c623947c4fa00e6d4b51792c7972ba09582bbcb7605beb373725c0dd666f2c81", "bug-fix/2026-07-26-intent-draft-same-tick-echo.md": "sha256:fa8b1417b2cdd3deecbf8e55bdddd73dd3a8c6e3486fd399b0b8bdf317e56373", "bug-fix/2026-07-26-intent-draft-same-tick-echo.zh.md": "sha256:00ce72552dbaa11562fbc541343a5d33f9449edabbe6dd354eb879a7d4d530f8", + "bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml": "sha256:8613a1cfcf4b9c7fafa78a8d8565e2a65ef0335b7b826af9b2bb32097836af55", + "bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md": "sha256:1bd344aec5454d2a2d6e1e6a32eff035c4a99c3df409f2624b39fd32e23ee402", + "bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md": "sha256:0a1747006efb1a4b67feceb9b627a437a0f023158e90ae86e1fe8aef76485384", "feature/2026-06-14-acp-agent-client-protocol.i18n.yaml": "sha256:006795baa43ae962a8d125cc0f1e9f134bc2ee9fb758b6e7669e3fa0126e1918", "feature/2026-06-14-acp-agent-client-protocol.md": "sha256:6828c0af74bb3fb96206ca6b21c0e56a000b50e4744aad4bc2c05092f3a5a31b", "feature/2026-06-14-acp-agent-client-protocol.zh.md": "sha256:ba104e841a1fb84edbd3b6c8119d50445b7785255a7a8d13bb9ac8a2cb4d2e69", From 6e577843c83d57cc69bc3b4cfa2e843d10f41e53 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 16:09:05 +0800 Subject: [PATCH 15/52] feat(session-query): expose projectSessions batch projection Public SessionQueryService.projectSessions wraps the existing corpus projectMany: one persistence listing, bounded persisted-inspect concurrency, per-id failure isolation, and a synchronous projector over a borrowed source with no replay validation or cloning. readTitleSnapshots now routes through it; LogicalSessionSource and LogicalProjectionResult are exported and documented. --- docs/cordis-catalog/services.md | 21 ++++++++++++-- .../session-query.i18n.yaml | 6 ++-- docs/core-data-structures/session-query.md | 19 +++++++++++++ docs/core-data-structures/session-query.zh.md | 19 +++++++++++++ .../cordis/tool-cordis/src/api-catalog.ts | 12 ++++++++ .../session-query/README.i18n.yaml | 4 +-- .../session-query/session-query/README.md | 3 +- .../session-query/session-query/README.zh.md | 3 +- .../session-query/session-query/src/index.ts | 28 +++++++++++++++++-- .../session-query/tests/session-query.spec.ts | 27 ++++++++++++++++++ scripts/gen-cordis-catalog.ts | 2 ++ scripts/type-equiv.manifest.json | 10 +++++++ 12 files changed, 143 insertions(+), 11 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index d92f467469..000c12f27a 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1442,6 +1442,23 @@ async readTitleSnapshot( sessionId: SessionId, signal?: AbortSignal, ): Promise< */ async readTitleSnapshots( sessionIds: readonly SessionId[], signal?: AbortSignal, ): Promise +/** + * Project unique logical sessions synchronously from one cancellable corpus + * observation. + * + * Each source is a borrowed raw log without replay validation or cloning, so + * a batch summary costs one bounded read per persisted session instead of a + * full validated copy; the projector must clone anything it retains beyond + * its own call. Results preserve first-occurrence input order. Operational + * failures stay isolated per session, while cancellation rejects the + * complete operation. + * @param sessionIds - live or persisted session ids to observe. + * @param project - synchronous fold that owns/clones every retained value. + * @param signal - optional cancellation shared by all source reads. + * @returns one fulfilled or rejected result per unique requested id. + */ +async projectSessions( sessionIds: readonly SessionId[], project: (source: LogicalSessionSource) => Value, signal?: AbortSignal, ): Promise[]> + /** * List lightweight raw-log event records for one logical session. * @param sessionId - live-preferred session id to read. @@ -1492,9 +1509,9 @@ async traceEvent(request: SessionEventTraceRequest, signal?: AbortSignal): Promi async readEvent(request: SessionEventReadRequest, signal?: AbortSignal): Promise ``` -Types: [SessionEventReadRequest](../core-data-structures/session-query.md) · [SessionEventRecord](../core-data-structures/session-query.md) · [SessionEventResultFilter](../core-data-structures/session-query.md) · [SessionEventSearchDocument](../core-data-structures/session-query.md) · [SessionEventSearchPage](../core-data-structures/session-query.md) · [SessionEventSearchRequest](../core-data-structures/session-query.md) · [SessionEventTraceObservation](../core-data-structures/session-query.md) · [SessionEventTraceRequest](../core-data-structures/session-query.md) · [SessionEventWindow](../core-data-structures/session-query.md) · [SessionId](../core-data-structures/core.md) · [SessionLineageTrace](../core-data-structures/session-query.md) · [SessionLogSnapshot](../core-data-structures/session-query.md) · [SessionRecord](../core-data-structures/session-query.md) · [SessionResultFilter](../core-data-structures/session-query.md) · [SessionSearchExecContext](../core-data-structures/session-query.md) · [SessionSearchHit](../core-data-structures/session-query.md) · [SessionSearchPage](../core-data-structures/session-query.md) · [SessionSearchRequest](../core-data-structures/session-query.md) · [SessionSurfaceSnapshot](../core-data-structures/session-query.md) · [SessionTitleObservation](../core-data-structures/session-query.md) · [SessionTitleObservationResult](../core-data-structures/session-query.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) +Types: [LogicalProjectionResult](../core-data-structures/session-query.md) · [LogicalSessionSource](../core-data-structures/session-query.md) · [SessionEventReadRequest](../core-data-structures/session-query.md) · [SessionEventRecord](../core-data-structures/session-query.md) · [SessionEventResultFilter](../core-data-structures/session-query.md) · [SessionEventSearchDocument](../core-data-structures/session-query.md) · [SessionEventSearchPage](../core-data-structures/session-query.md) · [SessionEventSearchRequest](../core-data-structures/session-query.md) · [SessionEventTraceObservation](../core-data-structures/session-query.md) · [SessionEventTraceRequest](../core-data-structures/session-query.md) · [SessionEventWindow](../core-data-structures/session-query.md) · [SessionId](../core-data-structures/core.md) · [SessionLineageTrace](../core-data-structures/session-query.md) · [SessionLogSnapshot](../core-data-structures/session-query.md) · [SessionRecord](../core-data-structures/session-query.md) · [SessionResultFilter](../core-data-structures/session-query.md) · [SessionSearchExecContext](../core-data-structures/session-query.md) · [SessionSearchHit](../core-data-structures/session-query.md) · [SessionSearchPage](../core-data-structures/session-query.md) · [SessionSearchRequest](../core-data-structures/session-query.md) · [SessionSurfaceSnapshot](../core-data-structures/session-query.md) · [SessionTitleObservation](../core-data-structures/session-query.md) · [SessionTitleObservationResult](../core-data-structures/session-query.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) -Source: [`packages/session-query/session-query/src/index.ts:81`](../../packages/session-query/session-query/src/index.ts) +Source: [`packages/session-query/session-query/src/index.ts:82`](../../packages/session-query/session-query/src/index.ts) ## `ctx.sessionReferences` — `SessionReferenceService` diff --git a/docs/core-data-structures/session-query.i18n.yaml b/docs/core-data-structures/session-query.i18n.yaml index f9c7355148..fc00c40e0a 100644 --- a/docs/core-data-structures/session-query.i18n.yaml +++ b/docs/core-data-structures/session-query.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -session-query.md: d92af4bac34f7d41457e9e193111c3a53fe8022e -session-query.zh.md: ecf330b0a361ffae352a91c0d35524444936606d +# pnpm run verify-translation-pairing --write docs/core-data-structures/session-query.md +session-query.md: cffbd792e8cab6e79365ceb4e0b1d996e7e93cf5 +session-query.zh.md: 2b67761ec3bcd96ee9de15511ec44516f1fe525d diff --git a/docs/core-data-structures/session-query.md b/docs/core-data-structures/session-query.md index d92af4bac3..cffbd792e8 100644 --- a/docs/core-data-structures/session-query.md +++ b/docs/core-data-structures/session-query.md @@ -84,6 +84,25 @@ type SessionTitleObservationResult = } ``` +`projectSessions` batches arbitrary synchronous folds over the same live-preferred corpus: each `LogicalSessionSource` is a borrowed raw log — never replay-validated or cloned — that is valid only for the projector call, so a batch summary costs one bounded read per persisted session. Each `LogicalProjectionResult` settles per unique requested id under the same isolation and cancellation rules as batch title reads. + +```ts type-equiv +/** Borrowed source visible only during one synchronous batch projection. */ +interface LogicalSessionSource { + /** Header selected with `events`; callers must clone retained output. */ + readonly header: SessionHeader + /** Raw events selected with `header`; valid only for the projection call. */ + readonly events: readonly SessionEvent[] +} +``` + +```ts type-equiv +/** One source-projection result in a batch logical-corpus observation. */ +type LogicalProjectionResult = + | { sessionId: SessionId; status: 'fulfilled'; value: Value } + | { sessionId: SessionId; status: 'rejected'; reason: unknown } +``` + ```ts type-equiv /** Lightweight metadata for one event within a logical session. */ interface SessionEventRecord { diff --git a/docs/core-data-structures/session-query.zh.md b/docs/core-data-structures/session-query.zh.md index ecf330b0a3..2b67761ec3 100644 --- a/docs/core-data-structures/session-query.zh.md +++ b/docs/core-data-structures/session-query.zh.md @@ -84,6 +84,25 @@ type SessionTitleObservationResult = } ``` +`projectSessions` 在同一实时优先语料库上批量执行任意同步折叠:每个 `LogicalSessionSource` 都是借用的原始日志——从不做回放验证,也从不克隆——仅在投影函数调用期间有效,因此一次批量摘要对每个持久化会话只需一次有界读取。每个 `LogicalProjectionResult` 按唯一请求 id 结算,其失败隔离与取消规则与批量标题读取一致。 + +```ts type-equiv +/** Borrowed source visible only during one synchronous batch projection. */ +interface LogicalSessionSource { + /** Header selected with `events`; callers must clone retained output. */ + readonly header: SessionHeader + /** Raw events selected with `header`; valid only for the projection call. */ + readonly events: readonly SessionEvent[] +} +``` + +```ts type-equiv +/** One source-projection result in a batch logical-corpus observation. */ +type LogicalProjectionResult = + | { sessionId: SessionId; status: 'fulfilled'; value: Value } + | { sessionId: SessionId; status: 'rejected'; reason: unknown } +``` + ```ts type-equiv /** Lightweight metadata for one event within a logical session. */ interface SessionEventRecord { diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index b888f33ed4..10c2a0bdcf 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -678,6 +678,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async readTitleSnapshots( sessionIds: readonly SessionId[], signal?: AbortSignal, ): Promise', jsDoc: '/**\n * Fold titles for unique sessions from one cancellable corpus observation.\n *\n * Results preserve first-occurrence input order. Operational failures stay\n * isolated per session, while cancellation rejects the complete operation.\n * @param sessionIds - live or persisted session ids to observe.\n * @param signal - optional cancellation shared by all source reads.\n * @returns one fulfilled or rejected result per unique requested id.\n */', }, + { + signature: 'async projectSessions( sessionIds: readonly SessionId[], project: (source: LogicalSessionSource) => Value, signal?: AbortSignal, ): Promise[]>', + jsDoc: '/**\n * Project unique logical sessions synchronously from one cancellable corpus\n * observation.\n *\n * Each source is a borrowed raw log without replay validation or cloning, so\n * a batch summary costs one bounded read per persisted session instead of a\n * full validated copy; the projector must clone anything it retains beyond\n * its own call. Results preserve first-occurrence input order. Operational\n * failures stay isolated per session, while cancellation rejects the\n * complete operation.\n * @param sessionIds - live or persisted session ids to observe.\n * @param project - synchronous fold that owns/clones every retained value.\n * @param signal - optional cancellation shared by all source reads.\n * @returns one fulfilled or rejected result per unique requested id.\n */', + }, { signature: 'async listEvents(sessionId: SessionId): Promise', jsDoc: '/**\n * List lightweight raw-log event records for one logical session.\n * @param sessionId - live-preferred session id to read.\n * @returns event records in ascending seq order.\n */', @@ -2055,6 +2059,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'LlmResolvedModelInfo', declaration: 'export interface LlmResolvedModelInfo extends LlmModelInfo {\n context?: LlmModelContext;\n defaultMaxTokens?: number;\n reasoning?: LlmModelReasoningInfo;\n}', }, + { + name: 'LogicalProjectionResult', + declaration: 'export type LogicalProjectionResult = {\n sessionId: SessionId;\n status: \'fulfilled\';\n value: Value;\n} | {\n sessionId: SessionId;\n status: \'rejected\';\n reason: unknown;\n};', + }, + { + name: 'LogicalSessionSource', + declaration: 'export interface LogicalSessionSource {\n readonly header: SessionHeader;\n readonly events: readonly SessionEvent[];\n}', + }, { name: 'Message', declaration: 'export interface Message {\n readonly id: MessageId;\n readonly role: \'system\' | \'user\' | \'assistant\';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n}', diff --git a/packages/session-query/session-query/README.i18n.yaml b/packages/session-query/session-query/README.i18n.yaml index fb266a82de..0ac53ddb14 100644 --- a/packages/session-query/session-query/README.i18n.yaml +++ b/packages/session-query/session-query/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-query/session-query/README.md -README.md: df97333be3b2c2cf71dd8c9287959bcbd83a5063 -README.zh.md: 1a3df1ce38360975d88a9f578b071b29cefbba0f +README.md: 15ab403100b45e35808e95f84dcd8ab521854c66 +README.zh.md: 5e3cbfa0d13ba4884d0eb2cc1b4506fbfdef2446 diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index df97333be3..15ab403100 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -11,13 +11,14 @@ English | [中文](README.zh.md) - `filterSessions(filters, signal?)` applies provider-independent session metadata and availability predicates to that same cloned logical corpus. - `filterEvents(sessionId, filters)` extracts first-party semantic documents and applies provider-independent metadata and literal-text predicates in ascending seq order. - `readTitleSnapshots(sessionIds, signal?)` resolves unique ids from one live-preferred corpus observation, passes cancellation through persisted listing and inspection, and returns ordered per-session settlements so one missing or malformed title source does not discard its peers. Each live source is folded directly, and each persisted worker folds to a detached header/title result and releases the full log before dequeuing another id. Cancellation rejects the whole batch. `readTitleSnapshot(sessionId, signal?)` is the one-observation view; `readTitle(sessionId, signal?)` returns only its optional folded `session/title`. +- `projectSessions(sessionIds, project, signal?)` runs one synchronous caller fold per unique id under the same batched corpus observation, isolation, and cancellation rules as `readTitleSnapshots`. Each source is a borrowed raw log — never replay-validated or cloned — valid only for the projector call, so a batch summary (for example the resume selector) scales with what the projector retains instead of total log size; the projector must clone anything it keeps. - `listEvents(sessionId)` loads the live-preferred raw log and classifies each event as `current`, `shadowed`, or `log-only` with the shared `dsh-session` surface fold. - `readSurface(sessionId)` returns one cloned header, raw-log capture boundary, and the complete folded current surface in model-history order. A live session wins over persistence; compaction is observed before or after its replacement append, never as a synthetic mixture. - `readEvent(request, signal?)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`. - `traceSession(sessionId, signal?)` reads the corpus once and returns immediate-to-outward ancestors plus deterministic recursive descendant trees. `complete: false` identifies the first missing parent; a target-connected cycle fails with `SESSION_QUERY_INVALID_LINEAGE`. - `traceEvent(request, signal?)` loads the logical log once and returns its cloned source header with direct positional replacements and direct logged provenance. `replacementChain` follows positional replacers to the final replacement; provenance links remain non-transitive. -Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A title read, event trace, or event read targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. Lineage-trace cancellation is passed to persisted listing; event-trace and event-read cancellation is passed to persisted listing and inspection. Each waits for the started backend call to settle, then rejects with the signal's exact reason even when the backend ignored that signal. A pre-aborted known-live title read, event trace, or event read rejects before folding or snapshotting without consulting persistence. A batch title observation performs one metadata listing, inspects its unique persisted ids with at most `persistedInspectConcurrency` workers, and preserves each title's own observed header for downstream authorization. Cancellation starts no queued inspections and rejects only after already-started workers settle. `listSessions()` remains lightweight and does not load logs or index titles. +Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A title read, event trace, or event read targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. Lineage-trace cancellation is passed to persisted listing; event-trace and event-read cancellation is passed to persisted listing and inspection. Each waits for the started backend call to settle, then rejects with the signal's exact reason even when the backend ignored that signal. A pre-aborted known-live title read, event trace, or event read rejects before folding or snapshotting without consulting persistence. A batch observation — titles or caller projections — performs one metadata listing, inspects its unique persisted ids with at most `persistedInspectConcurrency` workers, and preserves each result's own observed header for downstream authorization. Cancellation starts no queued inspections and rejects only after already-started workers settle. `listSessions()` remains lightweight and does not load logs or index titles. ## Filtering and extraction diff --git a/packages/session-query/session-query/README.zh.md b/packages/session-query/session-query/README.zh.md index 1a3df1ce38..5e3cbfa0d1 100644 --- a/packages/session-query/session-query/README.zh.md +++ b/packages/session-query/session-query/README.zh.md @@ -11,13 +11,14 @@ - `filterSessions(filters, signal?)` 对同一份克隆逻辑语料库应用与提供方无关的会话元数据和可用性谓词。 - `filterEvents(sessionId, filters)` 提取第一方语义文档,并按 seq 升序应用与提供方无关的元数据和字面文本谓词。 - `readTitleSnapshots(sessionIds, signal?)` 从一次实时优先的语料库观察中解析唯一 id,将取消信号传递给持久化列表查询和检查,并按顺序返回每个会话的结算结果,使某个缺失或格式错误的标题来源不会丢弃其他来源。每个实时来源直接 fold,每个持久化 worker fold 为脱离存储的 header/标题结果,并在出队下一个 id 前释放完整日志。取消会拒绝整个批次。`readTitleSnapshot(sessionId, signal?)` 是单次观察视图;`readTitle(sessionId, signal?)` 只返回其可选的 folded `session/title`。 +- `projectSessions(sessionIds, project, signal?)` 按唯一 id 各执行一次调用方的同步 fold,其批量语料库观察、失败隔离和取消规则与 `readTitleSnapshots` 相同。每个来源都是借用的原始日志——从不做回放验证,也从不克隆——仅在投影函数调用期间有效,因此一次批量摘要(例如恢复选择器)的开销取决于投影函数保留的内容,而不是日志总大小;投影函数必须克隆它要保留的任何值。 - `listEvents(sessionId)` 加载实时优先的原始日志,将每个事件分类为 `current`、`shadowed` 或 `log-only`;该分类使用共享 `dsh-session` 表层 fold。 - `readSurface(sessionId)` 返回一个克隆 header、原始日志捕获边界,以及按模型历史顺序排列的完整折叠后当前表层。实时会话优先于持久化;压缩(compaction)只会在其替换追加之前或之后被观察,绝不会出现合成混合。 - `readEvent(request, signal?)` 返回一个克隆 header、完整目标事件和有界的原始 seq 窗口。`before` 和 `after` 默认为 0,且不得超过 `readWindowMax`。 - `traceSession(sessionId, signal?)` 只读取一次语料库,返回从直接父级向外的祖先,以及确定性的递归后代树。`complete: false` 标识第一个缺失父级;与目标相连的循环会以 `SESSION_QUERY_INVALID_LINEAGE` 失败。 - `traceEvent(request, signal?)` 只加载一次逻辑日志,返回其克隆源 header、直接位置替换和直接已记录来源信息。`replacementChain` 沿位置替换者跟踪到最终替换;来源链接仍不传递。 -持久化是可选的,可动态挂载或卸载。已挂载持久化无法读取时,跨语料库列表和血缘跟踪以 `SESSION_QUERY_PERSISTENCE_FAILED` 失败。针对已知实时会话的标题读取、事件跟踪或事件读取不会查询持久化,因此持久化后端的健康状态无法使当前内存状态变得不可读。持久化标题和事件操作在加载前先执行列表查询,并在元数据不匹配时拒绝,而不会组合不一致的观察。血缘跟踪的取消信号会传递给持久化列表查询;事件跟踪和事件读取的取消信号会传递给持久化列表查询和检查。每项操作都会等待已启动的后端调用结算,然后使用信号的精确原因拒绝,即使后端忽略了该信号。针对已知实时会话且预先中止的标题读取、事件跟踪或事件读取会在 fold 或快照之前拒绝,且不查询持久化。批量标题观察执行一次元数据列表查询,使用最多 `persistedInspectConcurrency` 个 worker 检查唯一持久化 id,并保留每个标题自己观察到的 header,供下游授权使用。取消不会启动已排队检查,且只在已启动 worker 结算后拒绝。`listSessions()` 仍保持轻量,不加载日志或索引标题。 +持久化是可选的,可动态挂载或卸载。已挂载持久化无法读取时,跨语料库列表和血缘跟踪以 `SESSION_QUERY_PERSISTENCE_FAILED` 失败。针对已知实时会话的标题读取、事件跟踪或事件读取不会查询持久化,因此持久化后端的健康状态无法使当前内存状态变得不可读。持久化标题和事件操作在加载前先执行列表查询,并在元数据不匹配时拒绝,而不会组合不一致的观察。血缘跟踪的取消信号会传递给持久化列表查询;事件跟踪和事件读取的取消信号会传递给持久化列表查询和检查。每项操作都会等待已启动的后端调用结算,然后使用信号的精确原因拒绝,即使后端忽略了该信号。针对已知实时会话且预先中止的标题读取、事件跟踪或事件读取会在 fold 或快照之前拒绝,且不查询持久化。批量观察——标题或调用方投影——执行一次元数据列表查询,使用最多 `persistedInspectConcurrency` 个 worker 检查唯一持久化 id,并保留每个结果自己观察到的 header,供下游授权使用。取消不会启动已排队检查,且只在已启动 worker 结算后拒绝。`listSessions()` 仍保持轻量,不加载日志或索引标题。 ## 过滤与提取 diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index 809971b798..cf3dd95e39 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -36,7 +36,7 @@ import { SessionQueryError, type Config, } from './config.ts' -import { SessionCorpus } from './corpus.ts' +import { SessionCorpus, type LogicalProjectionResult, type LogicalSessionSource } from './corpus.ts' import { buildSessionEventSearchDocuments } from './documents.ts' import { filterSessionEventDocuments, @@ -64,6 +64,7 @@ export { materializeSessionResultFilters, } from './filters.ts' export { assertSessionHeadersCompatible } from './sources.ts' +export type { LogicalProjectionResult, LogicalSessionSource } from './corpus.ts' declare module 'cordis' { interface Context { @@ -205,7 +206,7 @@ export abstract class SessionQueryService extends Service { sessionIds: readonly SessionId[], signal?: AbortSignal, ): Promise { - return this._corpus.projectMany(sessionIds, (source): SessionTitleObservation => { + return this.projectSessions(sessionIds, (source): SessionTitleObservation => { const title = foldSessionTitle(source.events) return { session: structuredClone(source.header), @@ -214,6 +215,29 @@ export abstract class SessionQueryService extends Service { }, signal) } + /** + * Project unique logical sessions synchronously from one cancellable corpus + * observation. + * + * Each source is a borrowed raw log without replay validation or cloning, so + * a batch summary costs one bounded read per persisted session instead of a + * full validated copy; the projector must clone anything it retains beyond + * its own call. Results preserve first-occurrence input order. Operational + * failures stay isolated per session, while cancellation rejects the + * complete operation. + * @param sessionIds - live or persisted session ids to observe. + * @param project - synchronous fold that owns/clones every retained value. + * @param signal - optional cancellation shared by all source reads. + * @returns one fulfilled or rejected result per unique requested id. + */ + async projectSessions( + sessionIds: readonly SessionId[], + project: (source: LogicalSessionSource) => Value, + signal?: AbortSignal, + ): Promise[]> { + return this._corpus.projectMany(sessionIds, project, signal) + } + /** * List lightweight raw-log event records for one logical session. * @param sessionId - live-preferred session id to read. diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 2713de9a72..0613673f88 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -548,6 +548,33 @@ describe('session-query exact reads', () => { expect(TestPersistence.inspectSignals).toEqual([signal, signal]) }) + it('projects borrowed raw logs in one corpus scan with per-session failure isolation', async () => { + const persisted = header('project-persisted', 1) + TestPersistence.reset([{ meta: persisted, events: eventLog('persisted-projection') }]) + const ctx = await liveContext() + const live = ctx.sessions.create(SessionId('project-live'), { meta: { createdAt: 2 } }) + live.append('session/title', { + title: 'Live projection', + messageSeqs: [], + source: { kind: 'fallback' }, + }) + await ctx.plugin(TestPersistence) + const missing = SessionId('project-missing') + + const results = await ctx.sessionQuery.projectSessions( + [live.id, persisted.id, missing], + source => ({ id: source.header.id, eventCount: source.events.length }), + ) + + expect(results).toMatchObject([ + { sessionId: live.id, status: 'fulfilled', value: { id: live.id, eventCount: 1 } }, + { sessionId: persisted.id, status: 'fulfilled', value: { id: persisted.id, eventCount: 1 } }, + { sessionId: missing, status: 'rejected' }, + ]) + expect(TestPersistence.listCalls).toBe(1) + expect(TestPersistence.inspectCalls).toEqual([persisted.id]) + }) + it('bounds persisted title inspection concurrency while preserving ordered results', async () => { const entries = Array.from({ length: 12 }, (_, index) => { const meta = header(`bounded-title-${index}`, index) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 86368b03ed..a5a17bbb5a 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -55,6 +55,8 @@ export const LINK_MAP: Readonly> = { SessionEvent: 'core.md', SessionId: 'core.md', SessionStartSource: 'core.md', + LogicalProjectionResult: 'session-query.md', + LogicalSessionSource: 'session-query.md', SessionLogSnapshot: 'session-query.md', SessionSurfaceSnapshot: 'session-query.md', ApprovalOutcome: 'approval.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index d18bd54268..d511bfecaa 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -449,6 +449,16 @@ "symbol": "SessionTitleObservationResult", "source": "packages/session-query/session-query/src/types.ts" }, + { + "doc": "docs/core-data-structures/session-query.md", + "symbol": "LogicalSessionSource", + "source": "packages/session-query/session-query/src/corpus.ts" + }, + { + "doc": "docs/core-data-structures/session-query.md", + "symbol": "LogicalProjectionResult", + "source": "packages/session-query/session-query/src/corpus.ts" + }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventRecord", From 3c08ca36066da90664b6426513cc4c83518f6057 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 16:09:17 +0800 Subject: [PATCH 16/52] perf(tui): open the /resume selector from one batch projection The selector called readSession per listed session under an unbounded Promise.all: each call re-listed the whole persistence store (O(N^2) listings), decompressed and parsed the complete log, replay-validated every event, and deep-cloned it up to three times, only to derive one row's title, activity time, turn label, route, and goal phase. On a real 185-session / 87 MB store the selector took tens of seconds. Candidate rows now come from one projectSessions batch over borrowed logs; a rejected projection degrades to the same disabled unreadable row. Preflight still replay-validates the single chosen session through readSession, which is already live-preferred, so its redundant live shortcut is gone. --- ...resume-selector-batch-projection.i18n.yaml | 6 ++ ...-07-31-resume-selector-batch-projection.md | 27 ++++++ ...-31-resume-selector-batch-projection.zh.md | 27 ++++++ packages/ui/tui/src/chat/resume.ts | 89 +++++++++++-------- packages/ui/tui/src/components/dialogs.ts | 36 ++++---- packages/ui/tui/tests/tui.spec.ts | 56 ++++++++---- 6 files changed, 173 insertions(+), 68 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml new file mode 100644 index 0000000000..c0faa37e8d --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md +2026-07-31-resume-selector-batch-projection.md: 0aad3d57079819165d345d494071eb04d0b50abd +2026-07-31-resume-selector-batch-projection.zh.md: 055e11c440114987c0c37a2d268bce3328d4b1bd diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md new file mode 100644 index 0000000000..0aad3d5707 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md @@ -0,0 +1,27 @@ +# Agent Note: Resume selector batch projection + +Status: implemented + +English | [中文](2026-07-31-resume-selector-batch-projection.zh.md) + +## Problem + +Opening the TUI `/resume` selector called `sessionQuery.readSession()` once per listed session under an unbounded `Promise.all`. Each call re-listed the whole persistence store inside `SessionCorpus.load()` (O(N²) listings), read and decompressed the complete log, replay-validated every event through the `Session` constructor, and deep-cloned the header and events up to three times — all to derive one selector row's title, last-activity time, last `turn/end` label, provider/model route, and goal phase. On a real store (185 sessions, 87 MB compressed, ~353k events) the selector took tens of seconds to open, and the cost grows with total log size rather than session count. + +## Decision + +`SessionQueryService` exposes the existing internal `SessionCorpus.projectMany` batch as public `projectSessions(sessionIds, project, signal?)`: one persistence listing, at most `persistedInspectConcurrency` concurrent persisted inspections, per-id failure isolation, and a synchronous projector over a borrowed `LogicalSessionSource` with no replay validation and no cloning. `readTitleSnapshots` now routes through it; `LogicalSessionSource` and `LogicalProjectionResult` are exported and documented in the session-query core-data-structures page. + +The `/resume` selector builds all candidate rows from one `projectSessions` batch; a rejected projection degrades to that row's disabled "Unreadable session" fallback exactly as a failed `readSession` did. `summarizeResumeCandidate` takes the borrowed source and retains only the record and derived scalars. The pre-handoff preflight still reads the single chosen session through `readSession`, keeping full replay validation before the process re-execs; its redundant live-session shortcut was dropped because `readSession` is already live-preferred. + +## Alternatives considered + +**Fix only the O(N²) listing inside `SessionCorpus.load()`.** Rejected as the primary fix: the per-candidate full decompress, replay validation, and triple clone dominate on large logs and remain O(total log bytes). The redundant pre-listing in `load()` is still a candidate cleanup, but it changes not-found/consistency error semantics and is not needed once the selector stops calling `readSession` per row. + +**A resume-specific summary method on `sessionQuery`.** Rejected: resume is a TUI concept, and the service seam should not import consumer vocabulary. The generic synchronous projection mirrors the seam `readTitleSnapshots` already used internally and lets the TUI own its fold. + +**A persisted summary index (e.g. in the SQLite query backend).** Rejected for now: one bounded pass over the store (~1–3 s on the measured machine) is acceptable selector latency, and an index adds an invalidation contract. Reintroduce if stores grow to where one bounded pass is still too slow. + +## Consequences + +Opening `/resume` performs one listing plus one bounded-concurrency pass instead of N listings and N validated full copies; memory stays bounded by the concurrency limit because each projected log is released before its worker dequeues another id. Selector rows are no longer replay-validated — a log that lists and parses but would fail replay shows as a normal row until preflight rejects it, which preflight always re-checks before handoff. Fake `sessionQuery` services in TUI tests must now provide `projectSessions` alongside `listSessions`/`readSession`. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md new file mode 100644 index 0000000000..055e11c440 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 恢复选择器批量投影 + +Status: implemented + +[English](2026-07-31-resume-selector-batch-projection.md) | 中文 + +## Problem + +打开 TUI `/resume` 选择器时,会在一个无界 `Promise.all` 中对每个列出的会话调用一次 `sessionQuery.readSession()`。每次调用都会在 `SessionCorpus.load()` 内部重新列出整个持久化存储(O(N²) 次列表查询)、读取并解压完整日志、通过 `Session` 构造函数对每个事件做回放验证,并将 header 和事件深克隆多达三次——而这一切只为推导一行选择器条目的标题、最近活动时间、最后一个 `turn/end` 标签、提供方/模型路由和目标阶段。在真实存储上(185 个会话、压缩后 87 MB、约 35.3 万个事件),选择器需要数十秒才能打开,且开销随日志总大小而非会话数量增长。 + +## Decision + +`SessionQueryService` 将既有的内部 `SessionCorpus.projectMany` 批量能力公开为 `projectSessions(sessionIds, project, signal?)`:一次持久化列表查询、最多 `persistedInspectConcurrency` 个并发持久化检查、按 id 隔离失败,以及一个在借用的 `LogicalSessionSource` 上运行的同步投影函数——不做回放验证也不克隆。`readTitleSnapshots` 现在经由它实现;`LogicalSessionSource` 和 `LogicalProjectionResult` 被导出,并记录在 session-query 核心数据结构页面中。 + +`/resume` 选择器通过一次 `projectSessions` 批量调用构建全部候选行;被拒绝的投影会退化为该行的禁用"Unreadable session"回退,与之前 `readSession` 失败时的行为完全一致。`summarizeResumeCandidate` 接受借用的来源,且只保留记录和推导出的标量。移交前的预检仍通过 `readSession` 读取用户选中的单个会话,在进程 re-exec 前保留完整回放验证;其中冗余的实时会话捷径被删除,因为 `readSession` 本身已是实时优先。 + +## Alternatives considered + +**只修复 `SessionCorpus.load()` 内部的 O(N²) 列表查询。** 作为主要修复被拒绝:在大日志上,按候选行执行的完整解压、回放验证和三重克隆才是主要开销,且仍是 O(日志总字节数)。`load()` 中的冗余预列表查询仍是一个候选清理项,但它会改变 not-found/一致性错误语义,而且一旦选择器不再按行调用 `readSession`,这项清理就不再必要。 + +**在 `sessionQuery` 上添加恢复专用的摘要方法。** 被拒绝:恢复是 TUI 概念,服务接缝不应引入消费者词汇。通用同步投影复用了 `readTitleSnapshots` 已在内部使用的接缝,并让 TUI 拥有自己的 fold。 + +**持久化摘要索引(例如放在 SQLite 查询后端中)。** 暂时被拒绝:对存储做一次有界扫描(在测量机器上约 1–3 秒)是可接受的选择器延迟,而索引会引入失效契约。若存储增长到一次有界扫描仍然过慢时再重新引入。 + +## Consequences + +打开 `/resume` 只执行一次列表查询加一次有界并发扫描,而不是 N 次列表查询和 N 份经验证的完整副本;内存受并发上限约束,因为每个投影完的日志会在其 worker 出队下一个 id 前被释放。选择器行不再经过回放验证——一份可列出、可解析但回放会失败的日志会显示为普通行,直到预检拒绝它,而预检在移交前总会重新检查。TUI 测试中的伪造 `sessionQuery` 服务现在必须在 `listSessions`/`readSession` 之外提供 `projectSessions`。 diff --git a/packages/ui/tui/src/chat/resume.ts b/packages/ui/tui/src/chat/resume.ts index 18f7944fc2..e6558ee4c3 100644 --- a/packages/ui/tui/src/chat/resume.ts +++ b/packages/ui/tui/src/chat/resume.ts @@ -1,6 +1,6 @@ /** * Session-resume sub-controller for the interactive chat channel: the - * `/resume` selector, per-candidate summary reads that tolerate a corrupt + * `/resume` selector, one batch summary projection that tolerates a corrupt * neighbor, the pre-handoff preflight, and the terminal handoff itself. * @module @deepseek-ai/dsh-tui/chat/resume */ @@ -10,7 +10,7 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import { errorChain } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' import type { - SessionLogSnapshot, + LogicalSessionSource, SessionQueryService, SessionRecord, } from '@deepseek-ai/dsh-session-query' @@ -66,44 +66,45 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro const workspaceLabel = (cwd: string | undefined): string => runtime.formatCwd?.(cwd) ?? formatCwd(cwd) - /** Build one display candidate without letting a corrupt neighbor abort the selector. */ + /** Summarize one record from a borrowed source, retaining only the record and derived scalars. */ + const summarize = ( + record: SessionRecord, + source: LogicalSessionSource, + providers: ReadonlySet, + ): ResumeCandidate => summarizeResumeCandidate( + record, + source, + agent.session.id, + agent.session.header.cwd, + providers, + workspaceLabel, + ) + + /** The disabled fallback row for a session whose log cannot be summarized. */ + const unreadableCandidate = (record: SessionRecord, error: unknown): ResumeCandidate => ({ + record, + title: 'Unreadable session', + lastActivityAt: record.header.createdAt, + lastTurn: 'log unavailable', + currentWorkspace: record.header.cwd === agent.session.header.cwd, + workspaceLabel: workspaceLabel(record.header.cwd), + disabledReason: `session cannot be loaded: ${errorChain(error)}`, + }) + + /** Build one exact candidate from a live-preferred read that replay-validates a persisted log. */ const readResumeCandidate = async ( record: SessionRecord, providers: ReadonlySet, ): Promise => { try { - let snapshot: SessionLogSnapshot - const live = ctx.sessions.get(record.header.id) - if (live !== undefined) { - snapshot = { - session: structuredClone(live.header), - events: live.events.map(event => structuredClone(event)), - } - } else { - const readQuery = sessionQuery() - /* v8 ignore start -- caller proves the optional service before mapping records */ - if (readQuery === undefined) throw new Error('session query is unavailable') - /* v8 ignore stop */ - snapshot = await readQuery.readSession(record.header.id) - } - return summarizeResumeCandidate( - record, - snapshot, - agent.session.id, - agent.session.header.cwd, - providers, - workspaceLabel, - ) + const readQuery = sessionQuery() + /* v8 ignore start -- caller proves the optional service before mapping records */ + if (readQuery === undefined) throw new Error('session query is unavailable') + /* v8 ignore stop */ + const snapshot = await readQuery.readSession(record.header.id) + return summarize(record, { header: snapshot.session, events: snapshot.events }, providers) } catch (error: unknown) { - return { - record, - title: 'Unreadable session', - lastActivityAt: record.header.createdAt, - lastTurn: 'log unavailable', - currentWorkspace: record.header.cwd === agent.session.header.cwd, - workspaceLabel: workspaceLabel(record.header.cwd), - disabledReason: `session cannot be loaded: ${errorChain(error)}`, - } + return unreadableCandidate(record, error) } } @@ -199,7 +200,25 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro // Every workspace in the store is summarized; the picker owns the // current-workspace/all-workspaces scope split over the whole set. const providers = new Set(ctx.llm.listProviders().map(provider => provider.id)) - const candidates = await Promise.all(records.map(record => readResumeCandidate(record, providers))) + // One bounded batch projection over borrowed logs: unlike a + // per-candidate readSession, it lists persistence once and skips + // replay validation and log cloning, so opening the selector scales + // with session count instead of total log size. A corrupt neighbor + // degrades to one disabled row. + const recordById = new Map(records.map(record => [record.header.id, record])) + const listedRecord = (id: SessionId): SessionRecord => { + const record = recordById.get(id) + /* v8 ignore next 2 -- projection ids come from this map; the corpus verifies each loaded header id */ + if (record === undefined) throw new Error(`resume scan returned unlisted session "${id}"`) + return record + } + const results = await listQuery.projectSessions( + records.map(record => record.header.id), + source => summarize(listedRecord(source.header.id), source, providers), + ) + const candidates = results.map(result => result.status === 'fulfilled' + ? result.value + : unreadableCandidate(listedRecord(result.sessionId), result.reason)) candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt || a.record.header.id.localeCompare(b.record.header.id)) if (deps.isDisposed() || scan !== resumeScan) return diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts index bad6a5458b..604f1858de 100644 --- a/packages/ui/tui/src/components/dialogs.ts +++ b/packages/ui/tui/src/components/dialogs.ts @@ -28,7 +28,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session' import { foldGoal, type GoalPhase } from '@deepseek-ai/dsh-goal' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' import type { - SessionLogSnapshot, + LogicalSessionSource, SessionRecord, } from '@deepseek-ai/dsh-session-query' import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction' @@ -453,8 +453,8 @@ export interface ResumeCandidate { disabledReason?: string } -function resumeTurnLabel(snapshot: SessionLogSnapshot): string { - const event = snapshot.events.findLast(item => item.type === 'turn/end') +function resumeTurnLabel(source: LogicalSessionSource): string { + const event = source.events.findLast(item => item.type === 'turn/end') if (event === undefined) return 'no completed turn' const reason = event.data.reason switch (reason.kind) { @@ -468,24 +468,26 @@ function resumeTurnLabel(snapshot: SessionLogSnapshot): string { } } -function resumeRoute(snapshot: SessionLogSnapshot): ResumeRoute | undefined { - const header = snapshot.events.findLast(item => item.type === 'request/header') +function resumeRoute(source: LogicalSessionSource): ResumeRoute | undefined { + const header = source.events.findLast(item => item.type === 'request/header') if (header?.type === 'request/header') { return { provider: header.data.header.config.provider, model: header.data.header.config.model } } - const assistant = snapshot.events.findLast(item => item.type === 'assistant/message') + const assistant = source.events.findLast(item => item.type === 'assistant/message') return assistant?.type === 'assistant/message' ? { provider: assistant.data.message.source.provider, model: assistant.data.message.source.model } : undefined } /** - * Build one resume selector row from a record and its log snapshot, deriving the - * title, route, goal phase, workspace scope, and any reason the session cannot - * be resumed here. A workspace other than the current one is a scope, not a - * disabled reason: resuming it hands the process off into that directory. + * Build one resume selector row from a record and its borrowed log source, + * deriving the title, route, goal phase, workspace scope, and any reason the + * session cannot be resumed here. A workspace other than the current one is a + * scope, not a disabled reason: resuming it hands the process off into that + * directory. The result retains only the record and derived scalars, so a + * borrowed source stays valid for exactly this call. * @param record - The session record. - * @param snapshot - The session's log snapshot. + * @param source - The session's borrowed header and raw event log. * @param currentId - The current session id. * @param cwd - The CURRENT session's workspace, which decides the picker scope this row falls in. * @param availableProviders - Providers registered in this runtime. @@ -494,15 +496,15 @@ function resumeRoute(snapshot: SessionLogSnapshot): ResumeRoute | undefined { */ export function summarizeResumeCandidate( record: SessionRecord, - snapshot: SessionLogSnapshot, + source: LogicalSessionSource, currentId: SessionId, cwd: string | undefined, availableProviders: ReadonlySet, formatWorkspace: (cwd: string | undefined) => string, ): ResumeCandidate { - const title = foldSessionTitle(snapshot.events)?.title ?? 'Untitled session' - const route = resumeRoute(snapshot) - const foldedGoal = foldGoal(snapshot.events).goal + const title = foldSessionTitle(source.events)?.title ?? 'Untitled session' + const route = resumeRoute(source) + const foldedGoal = foldGoal(source.events).goal let disabledReason: string | undefined if (record.header.id === currentId) disabledReason = 'current session' else if (record.live) disabledReason = 'session is already live in this runtime' @@ -514,8 +516,8 @@ export function summarizeResumeCandidate( record, title, // Excludes a prior pickup's boundary, or every browsed session floats up. - lastActivityAt: lastActivityTime(snapshot.events) ?? snapshot.session.createdAt, - lastTurn: resumeTurnLabel(snapshot), + lastActivityAt: lastActivityTime(source.events) ?? source.header.createdAt, + lastTurn: resumeTurnLabel(source), currentWorkspace: record.header.cwd === cwd, workspaceLabel: formatWorkspace(record.header.cwd), ...route === undefined ? {} : { route }, diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 8231dc34d1..3729a1bd5b 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -273,6 +273,20 @@ describe('goodbye message and /resume', () => { { type: 'turn/end', seq: 6, time: time + 6, data: { turn: 1, reason } }, { type: 'session/title', seq: 7, time: time + 7, data: { title, messageSeqs: [1], source: { kind: 'fallback' } } }, ] + /** Derive the selector's batch projection from a fake per-session readSession. */ + const projectViaReadSession = ( + readSession: (id: SessionId) => Promise<{ session: SessionHeader; events: SessionEvent[] }>, + ) => ( + ids: readonly SessionId[], + project: (source: { header: SessionHeader; events: readonly SessionEvent[] }) => unknown, + ) => Promise.all(ids.map(async (sessionId) => { + try { + const snapshot = await readSession(sessionId) + return { sessionId, status: 'fulfilled', value: project({ header: snapshot.session, events: snapshot.events }) } + } catch (reason) { + return { sessionId, status: 'rejected', reason } + } + })) it('prints the host goodbye message on exit', async () => { const result = await setup({ @@ -516,6 +530,7 @@ describe('goodbye message and /resume', () => { queryCtx = child child.provide('sessionQuery', { listSessions: async () => { listCalls++; return [] }, + projectSessions: async () => [], } as never) }, }) @@ -546,16 +561,18 @@ describe('goodbye message and /resume', () => { cwd: '/workspace', async configureContext(ctx) { ctx.provide('tools', { get: () => undefined } as never) + const readSession = () => Promise.resolve({ + session: target, + events: resumeEvents('Query-only persisted session'), + }) ctx.provide('sessionQuery', { listSessions: () => Promise.resolve([{ header: target, live: false, persisted: true, }]), - readSession: () => Promise.resolve({ - session: target, - events: resumeEvents('Query-only persisted session'), - }), + readSession, + projectSessions: projectViaReadSession(readSession), } as never) }, }) @@ -593,6 +610,7 @@ describe('goodbye message and /resume', () => { ctx.provide('tools', { get: () => undefined } as never) ctx.provide('sessionQuery', { listSessions: () => ++calls === 1 ? first.promise : Promise.resolve([]), + projectSessions: async () => [], } as never) }, }) @@ -685,16 +703,18 @@ describe('goodbye message and /resume', () => { handoffResume: handoff, async configureContext(ctx) { ctx.provide('tools', { get: () => undefined } as never) + const readSession = () => Promise.resolve({ + session: target, + events: resumeEvents('Live target'), + }) ctx.provide('sessionQuery', { listSessions: () => Promise.resolve([{ header: target, live: true, persisted: true, }]), - readSession: () => Promise.resolve({ - session: target, - events: resumeEvents('Live target'), - }), + readSession, + projectSessions: projectViaReadSession(readSession), } as never) }, }) @@ -815,12 +835,14 @@ describe('goodbye message and /resume', () => { async configureContext(ctx) { ctx.provide('tools', { get: () => undefined } as never) ctx.on('session/flush', flush) + const readSession = () => Promise.resolve({ + session: target, + events: resumeEvents('Dispose during preflight'), + }) ctx.provide('sessionQuery', { listSessions: () => ++listings === 1 ? Promise.resolve([record]) : secondListing.promise, - readSession: () => Promise.resolve({ - session: target, - events: resumeEvents('Dispose during preflight'), - }), + readSession, + projectSessions: projectViaReadSession(readSession), } as never) }, }) @@ -847,16 +869,18 @@ describe('goodbye message and /resume', () => { handoffResume: handoff, async configureContext(ctx) { ctx.provide('tools', { get: () => undefined } as never) + const readSession = () => Promise.resolve({ + session: target, + events: resumeEvents('Query without persistence'), + }) ctx.provide('sessionQuery', { listSessions: () => Promise.resolve([{ header: target, live: false, persisted: true, }]), - readSession: () => Promise.resolve({ - session: target, - events: resumeEvents('Query without persistence'), - }), + readSession, + projectSessions: projectViaReadSession(readSession), } as never) }, }) From b7ee38fef028167013791a6562378af5f47f0ca6 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 16:39:33 +0800 Subject: [PATCH 17/52] feat(tui): open the /resume picker immediately with a loading state The selector overlay opens as soon as the command dispatches: the picker renders a loading placeholder over an undefined candidate set, owns terminal input from its first frame, answers Enter with a still-loading error, and cancels on Escape exactly like the loaded list. The finished scan swaps rows in through setCandidates without replacing the overlay; a scan failure closes it and keeps the existing notice. --- ...resume-selector-batch-projection.i18n.yaml | 4 +- ...-07-31-resume-selector-batch-projection.md | 4 +- ...-31-resume-selector-batch-projection.zh.md | 4 +- packages/ui/tui/README.i18n.yaml | 4 +- packages/ui/tui/README.md | 2 +- packages/ui/tui/README.zh.md | 2 +- packages/ui/tui/src/chat/resume.ts | 60 ++++++++++++------- packages/ui/tui/src/components/dialogs.ts | 44 ++++++++++---- .../resume-sessions-loading.expected.txt | 47 +++++++++++++++ packages/ui/tui/tests/tui.snapshot.ts | 18 +++++- packages/ui/tui/tests/tui.spec.ts | 36 +++++++++++ 11 files changed, 181 insertions(+), 44 deletions(-) create mode 100644 packages/ui/tui/tests/snapshots/resume-sessions-loading.expected.txt diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml index c0faa37e8d..0e733bbb4d 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md -2026-07-31-resume-selector-batch-projection.md: 0aad3d57079819165d345d494071eb04d0b50abd -2026-07-31-resume-selector-batch-projection.zh.md: 055e11c440114987c0c37a2d268bce3328d4b1bd +2026-07-31-resume-selector-batch-projection.md: 1120a0777bf6a6ca17dd0704d145a3c742be0695 +2026-07-31-resume-selector-batch-projection.zh.md: 79db30170765925a00be180ba2b56de8a87ba130 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md index 0aad3d5707..1120a0777b 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md @@ -14,6 +14,8 @@ Opening the TUI `/resume` selector called `sessionQuery.readSession()` once per The `/resume` selector builds all candidate rows from one `projectSessions` batch; a rejected projection degrades to that row's disabled "Unreadable session" fallback exactly as a failed `readSession` did. `summarizeResumeCandidate` takes the borrowed source and retains only the record and derived scalars. The pre-handoff preflight still reads the single chosen session through `readSession`, keeping full replay validation before the process re-execs; its redundant live-session shortcut was dropped because `readSession` is already live-preferred. +The selector overlay opens synchronously when `/resume` dispatches, before the scan settles: an `undefined` candidate set renders a "Loading sessions…" placeholder, the picker owns terminal input from its first frame (so keystrokes during a long scan reach the search field rather than the editor), Enter reports that sessions are still loading, and Escape cancels exactly as it does on the loaded list. The finished scan swaps rows in through `setCandidates` without replacing the overlay; a queued activation behind a closing predecessor receives an already-scanned set at construction; a scan failure closes the overlay and reports the existing failure notice. + ## Alternatives considered **Fix only the O(N²) listing inside `SessionCorpus.load()`.** Rejected as the primary fix: the per-candidate full decompress, replay validation, and triple clone dominate on large logs and remain O(total log bytes). The redundant pre-listing in `load()` is still a candidate cleanup, but it changes not-found/consistency error semantics and is not needed once the selector stops calling `readSession` per row. @@ -24,4 +26,4 @@ The `/resume` selector builds all candidate rows from one `projectSessions` batc ## Consequences -Opening `/resume` performs one listing plus one bounded-concurrency pass instead of N listings and N validated full copies; memory stays bounded by the concurrency limit because each projected log is released before its worker dequeues another id. Selector rows are no longer replay-validated — a log that lists and parses but would fail replay shows as a normal row until preflight rejects it, which preflight always re-checks before handoff. Fake `sessionQuery` services in TUI tests must now provide `projectSessions` alongside `listSessions`/`readSession`. +Opening `/resume` performs one listing plus one bounded-concurrency pass instead of N listings and N validated full copies; memory stays bounded by the concurrency limit because each projected log is released before its worker dequeues another id. Selector rows are no longer replay-validated — a log that lists and parses but would fail replay shows as a normal row until preflight rejects it, which preflight always re-checks before handoff. Fake `sessionQuery` services in TUI tests must now provide `projectSessions` alongside `listSessions`/`readSession`. Because the picker takes focus immediately, starting a second scan requires dismissing the current overlay first — a second `/resume` typed during a scan lands in the search field, which is the intended input capture. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md index 055e11c440..79db301707 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md @@ -14,6 +14,8 @@ Status: implemented `/resume` 选择器通过一次 `projectSessions` 批量调用构建全部候选行;被拒绝的投影会退化为该行的禁用"Unreadable session"回退,与之前 `readSession` 失败时的行为完全一致。`summarizeResumeCandidate` 接受借用的来源,且只保留记录和推导出的标量。移交前的预检仍通过 `readSession` 读取用户选中的单个会话,在进程 re-exec 前保留完整回放验证;其中冗余的实时会话捷径被删除,因为 `readSession` 本身已是实时优先。 +选择器 overlay 在 `/resume` 分发时同步打开,早于扫描结算:`undefined` 候选集渲染"Loading sessions…"加载占位符,选择器从第一帧起就拥有终端输入(长扫描期间的按键会进入搜索字段而非编辑器),Enter 提示会话仍在加载,Escape 的取消方式与已加载列表完全相同。扫描完成后通过 `setCandidates` 换入行数据,不替换 overlay;排在正在关闭的前任之后的排队激活会在构造时直接收到已扫描的集合;扫描失败会关闭 overlay 并报告既有的失败通知。 + ## Alternatives considered **只修复 `SessionCorpus.load()` 内部的 O(N²) 列表查询。** 作为主要修复被拒绝:在大日志上,按候选行执行的完整解压、回放验证和三重克隆才是主要开销,且仍是 O(日志总字节数)。`load()` 中的冗余预列表查询仍是一个候选清理项,但它会改变 not-found/一致性错误语义,而且一旦选择器不再按行调用 `readSession`,这项清理就不再必要。 @@ -24,4 +26,4 @@ Status: implemented ## Consequences -打开 `/resume` 只执行一次列表查询加一次有界并发扫描,而不是 N 次列表查询和 N 份经验证的完整副本;内存受并发上限约束,因为每个投影完的日志会在其 worker 出队下一个 id 前被释放。选择器行不再经过回放验证——一份可列出、可解析但回放会失败的日志会显示为普通行,直到预检拒绝它,而预检在移交前总会重新检查。TUI 测试中的伪造 `sessionQuery` 服务现在必须在 `listSessions`/`readSession` 之外提供 `projectSessions`。 +打开 `/resume` 只执行一次列表查询加一次有界并发扫描,而不是 N 次列表查询和 N 份经验证的完整副本;内存受并发上限约束,因为每个投影完的日志会在其 worker 出队下一个 id 前被释放。选择器行不再经过回放验证——一份可列出、可解析但回放会失败的日志会显示为普通行,直到预检拒绝它,而预检在移交前总会重新检查。TUI 测试中的伪造 `sessionQuery` 服务现在必须在 `listSessions`/`readSession` 之外提供 `projectSessions`。由于选择器立即接管焦点,启动第二次扫描需要先关闭当前 overlay——扫描期间输入的第二个 `/resume` 会落入搜索字段,这正是预期的输入捕获行为。 diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index e94be1a857..6cfcd6d8fe 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/tui/README.md -README.md: 63c888b1d51c02fa85a8f0cc1617874debd87c4e -README.zh.md: ca5efc9ae26a9833d271991f73a21c607d8fb09d +README.md: 74dbf0dc26f99d0d7a6586fe6caec1f0ccdb2f36 +README.zh.md: d0ec1acbecf6e82ef35027a72268ba16fee65d6f diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 63c888b1d5..74dbf0dc26 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -32,7 +32,7 @@ The footer sums the session's reported usage as `↑ `/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, selected reasoning effort or default behavior, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer. -`/resume` opens a full-viewport keyboard selector instead of a centered dialog. Two scopes cover the same candidate set: the current workspace, which it opens on, and all workspaces, which Tab toggles to. The scope line under the search field names the active scope and the count the other holds, and each row in the all-workspaces scope also reports its own workspace. Toggling clears the search and selection so the highlighted row always belongs to the visible list. +`/resume` opens a full-viewport keyboard selector instead of a centered dialog. The selector opens as soon as the command runs and takes input focus while the session scan is still pending, showing a loading placeholder until the rows arrive; Escape cancels an in-flight scan the same way it cancels the loaded list. Two scopes cover the same candidate set: the current workspace, which it opens on, and all workspaces, which Tab toggles to. The scope line under the search field names the active scope and the count the other holds, and each row in the all-workspaces scope also reports its own workspace. Toggling clears the search and selection so the highlighted row always belongs to the visible list. Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Candidates are sorted by last logged activity and searchable by log-backed title or session id, and by workspace label in the all-workspaces scope; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, a session with no recorded workspace to run in, or a session whose logged provider has no current adapter remains visible but disabled; a workspace other than the current one is a scope rather than a disabled reason, because resume enters that directory. diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index ca5efc9ae2..d0ec1acbec 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -32,7 +32,7 @@ Footer 将会话报告的用量汇总为 `↑`;任 `/status` 会向 transcript 添加一张时间点诊断卡片,并在 agent 运行时保持可用。它报告会话 id、标题、工作目录、所选提供方/模型、所选推理强度或默认行为、reasoning 块可见性、agent 状态、事件/轮次/步骤/工具调用计数、精确输入/输出/缓存 token bucket、KV-cache 命中率、token-meter 上下文用量与容量、创建时间和最新事件时间。缺失标题、模型、缓存输入或上下文容量时会明确标记,而非推断。该卡片只存在于终端,不会重复紧凑 footer。 -`/resume` 会打开全 viewport 键盘选择器,而非居中对话框。两个作用域覆盖同一候选项集合:打开时所处的当前工作区,以及按 Tab 切换到的所有工作区。搜索字段下方的作用域行会给出当前作用域的名称以及另一个作用域包含的数量,且在所有工作区作用域中每行还会报告自身所属的工作区。切换会清除搜索与选择,使高亮行始终属于可见列表。 +`/resume` 会打开全 viewport 键盘选择器,而非居中对话框。选择器在命令执行时立即打开并接管输入焦点,会话扫描仍在进行时显示加载占位符,直到行数据就绪;Escape 取消进行中的扫描,方式与取消已加载列表相同。两个作用域覆盖同一候选项集合:打开时所处的当前工作区,以及按 Tab 切换到的所有工作区。搜索字段下方的作用域行会给出当前作用域的名称以及另一个作用域包含的数量,且在所有工作区作用域中每行还会报告自身所属的工作区。切换会清除搜索与选择,使高亮行始终属于可见列表。 获得焦点的搜索字段紧跟搜索 glyph 开始,并发出 pi-tui 的 cursor marker,使终端 IME 组合保持锚定在字段内。候选项按最近记录的活动排序,可按日志支持的标题或会话 id 搜索,在所有工作区作用域中还可按工作区标签搜索;每行报告 current/live/persisted 状态、上一轮次结果、近期提供方/模型,以及存在时的持久目标阶段。Up/Down 与 Page Up/Page Down 导航,Enter 恢复,Escape 会先清除非空搜索,再次按下才取消,Ctrl+C 则直接取消。当前会话、已在本运行时中活跃的会话、不可读日志、没有可运行的已记录工作区的会话,或日志所记提供方没有当前适配器的会话仍会显示,但不可选择;不同于当前工作区的工作区属于作用域而非禁用原因,因为恢复会进入该目录。 diff --git a/packages/ui/tui/src/chat/resume.ts b/packages/ui/tui/src/chat/resume.ts index e6558ee4c3..b1386ba10d 100644 --- a/packages/ui/tui/src/chat/resume.ts +++ b/packages/ui/tui/src/chat/resume.ts @@ -195,6 +195,38 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro } const scan = ++resumeScan void resumeOverlay?.close() + // The picker opens before the scan settles so the terminal stops feeding + // the editor immediately; a queued activation (the closing predecessor + // still holds the slot) receives an already-scanned set through + // `scanned` instead of a loading placeholder. + let picker: ResumePicker | undefined + let scanned: ResumeCandidate[] | undefined + const session = overlayManager.open({ + create: (host) => { + picker = new ResumePicker( + scanned, + resolved.maxResumeOptions, + workspaceLabel(agent.session.header.cwd), + () => host.viewport.rows, + palette, + (candidate) => { void handoffResume(candidate, session) }, + () => { void session.close() }, + ) + return picker + }, + options: { + width: '100%', + maxHeight: '100%', + anchor: 'top-left', + margin: 0, + }, + }) + resumeOverlay = session + void session.closed.then(() => { + /* v8 ignore next -- overlay FIFO closes this session before a replacement can become the tracked resume overlay */ + if (resumeOverlay === session) resumeOverlay = undefined + }) + deps.requestRender() void listQuery.listSessions().then(async (records) => { if (deps.isDisposed() || scan !== resumeScan) return // Every workspace in the store is summarized; the picker owns the @@ -222,31 +254,13 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt || a.record.header.id.localeCompare(b.record.header.id)) if (deps.isDisposed() || scan !== resumeScan) return - const session = overlayManager.open({ - create: host => new ResumePicker( - candidates, - resolved.maxResumeOptions, - workspaceLabel(agent.session.header.cwd), - () => host.viewport.rows, - palette, - (candidate) => { void handoffResume(candidate, session) }, - () => { void session.close() }, - ), - options: { - width: '100%', - maxHeight: '100%', - anchor: 'top-left', - margin: 0, - }, - }) - resumeOverlay = session - void session.closed.then(() => { - /* v8 ignore next -- overlay FIFO closes this session before a replacement can become the tracked resume overlay */ - if (resumeOverlay === session) resumeOverlay = undefined - }) + scanned = candidates + picker?.setCandidates(candidates) deps.requestRender() }, (error: unknown) => { - if (!deps.isDisposed() && scan === resumeScan) deps.appendNotice(`Resume session scan failed: ${errorChain(error)}`, 'error') + if (deps.isDisposed() || scan !== resumeScan) return + void session.close() + deps.appendNotice(`Resume session scan failed: ${errorChain(error)}`, 'error') }) }, } diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts index 604f1858de..6139a94773 100644 --- a/packages/ui/tui/src/components/dialogs.ts +++ b/packages/ui/tui/src/components/dialogs.ts @@ -537,6 +537,10 @@ export type ResumeScope = 'workspace' | 'all' * current session's workspace, `all` lists every workspace and labels each row * with its own. Tab toggles between them; the search query and selection reset * on a scope change so the highlighted row always belongs to the visible list. + * + * The picker opens before the session scan settles: an `undefined` candidate + * set renders a loading placeholder that keeps input away from the editor, + * and `setCandidates` swaps the scanned rows in without replacing the overlay. */ export class ResumePicker implements Component, Focusable { private readonly search = new Input() @@ -544,27 +548,41 @@ export class ResumePicker implements Component, Focusable { private selectedIndex = 0 private error = '' private scope: ResumeScope = 'workspace' + private candidates: readonly ResumeCandidate[] | undefined focused = false constructor( - private readonly candidates: readonly ResumeCandidate[], + candidates: readonly ResumeCandidate[] | undefined, private readonly maxVisible: number, private readonly workspaceLabel: string, private readonly viewportRows: () => number, private readonly palette: Palette, private readonly done: (candidate: ResumeCandidate) => void, private readonly cancel: () => void, - ) {} + ) { + this.candidates = candidates + } invalidate(): void { this.search.invalidate() } + /** + * Replace the loading placeholder with the scanned candidate set. + * @param candidates - the summarized rows the finished scan produced. + */ + setCandidates(candidates: readonly ResumeCandidate[]): void { + this.candidates = candidates + this.selectedIndex = 0 + this.invalidate() + } + /** Candidates in the active scope, before the search query narrows them. */ private scoped(): ResumeCandidate[] { + const candidates = this.candidates ?? [] return this.scope === 'all' - ? [...this.candidates] - : this.candidates.filter(candidate => candidate.currentWorkspace) + ? [...candidates] + : candidates.filter(candidate => candidate.currentWorkspace) } private filtered(): ResumeCandidate[] { @@ -646,7 +664,8 @@ export class ResumePicker implements Component, Focusable { this.error = '' } else if (matchesKey(data, Key.enter)) { const selected = filtered[this.selectedIndex] - if (selected === undefined) this.error = 'No session matches this search.' + if (this.candidates === undefined) this.error = 'Sessions are still loading.' + else if (selected === undefined) this.error = 'No session matches this search.' else if (selected.disabledReason !== undefined) this.error = selected.disabledReason else this.done(selected) } else { @@ -666,12 +685,13 @@ export class ResumePicker implements Component, Focusable { * workspace it means, and the inactive scope with the count Tab would reveal. */ private renderScopeLine(): string { - const inWorkspace = this.candidates.filter(candidate => candidate.currentWorkspace).length + const candidates = this.candidates ?? [] + const inWorkspace = candidates.filter(candidate => candidate.currentWorkspace).length const active = this.scope === 'workspace' ? `this workspace ${displayText(this.workspaceLabel)}` - : `all workspaces (${this.candidates.length})` + : `all workspaces (${candidates.length})` const other = this.scope === 'workspace' - ? `all workspaces (${this.candidates.length})` + ? `all workspaces (${candidates.length})` : `this workspace (${inWorkspace})` return `${this.palette.accent(active)}${this.palette.dim(` ⇥ ${other}`)}` } @@ -686,9 +706,12 @@ export class ResumePicker implements Component, Focusable { if (this.selectedIndex >= filtered.length) this.selectedIndex = Math.max(0, filtered.length - 1) const selected = filtered[this.selectedIndex] const position = selected === undefined ? 0 : this.selectedIndex + 1 + const title = this.candidates === undefined + ? 'Resume session' + : `Resume session (${position} of ${filtered.length})` const lines: string[] = [ '', - `${indent}${this.palette.bold(this.palette.accent(`Resume session (${position} of ${filtered.length})`))}`, + `${indent}${this.palette.bold(this.palette.accent(title))}`, '', ] @@ -737,7 +760,8 @@ export class ResumePicker implements Component, Focusable { push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`)) } } - if (filtered.length === 0) push(this.palette.warning('No matching sessions.')) + if (this.candidates === undefined) push(this.palette.dim('Loading sessions…')) + else if (filtered.length === 0) push(this.palette.warning('No matching sessions.')) if (this.error !== '') { lines.push('') push(this.palette.error(displayText(this.error))) diff --git a/packages/ui/tui/tests/snapshots/resume-sessions-loading.expected.txt b/packages/ui/tui/tests/snapshots/resume-sessions-loading.expected.txt new file mode 100644 index 0000000000..e44621ad60 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/resume-sessions-loading.expected.txt @@ -0,0 +1,47 @@ +terminal 92x32 buffer=normal length=32 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=6 viewportRow=4 bufferRow=4 +buffer +0| " " +1| " Resume session " + style 2-15 fg=bright-magenta bold +2| " " +3| " ╭──────────────────────────────────────────────────────────────────────────────────────╮ " + style 2-89 dim +4| " │ ⌕ │ " + style 2-2 dim + style 6-6 inverse + style 89-89 dim +5| " ╰──────────────────────────────────────────────────────────────────────────────────────╯ " + style 2-89 dim +6| " " +7| " this workspace /workspace/project ⇥ all workspaces (0) " + style 2-34 fg=bright-magenta + style 35-56 dim +8| " " +9| " Loading sessions… " + style 2-18 dim +10| " " +11| " " +12| " " +13| " " +14| " " +15| " " +16| " " +17| " " +18| " " +19| " " +20| " " +21| " " +22| " " +23| " " +24| " " +25| " " +26| " " +27| " " +28| " " +29| " " +30| " Type to search • ↑/↓ navigate • Tab scope • Enter resume • Esc clear/cancel " + style 2-84 dim +31| " " diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 18f0a9a793..91d1a67d78 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -57,6 +57,7 @@ const CHECKPOINTS = [ 'model-switching', 'errors-and-help', 'disposed-terminal', + 'resume-sessions-loading', 'resume-sessions', 'resume-sessions-all-workspaces', 'status-diagnostics', @@ -883,9 +884,13 @@ describe('TUI terminal-state snapshots', () => { { type: 'session/end-seed', seq: 8, time: Date.parse('2026-07-23T07:59:00.000Z'), data: {} }, ], }) + const listGate = Promise.withResolvers() const harness = await setupSnapshot({ sessionPersistence: { - list: async () => [earlier, elsewhere], + list: async () => { + await listGate.promise + return [earlier, elsewhere] + }, load: async id => id === elsewhere.id ? log(elsewhere, 'Other workspace work', '2024-02-02') : log(earlier, 'Resume selector design', '2024-01-01'), @@ -893,8 +898,15 @@ describe('TUI terminal-state snapshots', () => { }, { columns: 92, rows: 32 }) harness.terminal.send('/resume') harness.terminal.send('\r') - // `/resume` scans persistence asynchronously, so the listing renders a tick - // after submit (the unit suite waits the same way); settle, then flush. + // The picker opens as soon as the command dispatches and owns input while + // the persistence scan is still pending, rendering a loading placeholder + // in place of rows; only the scan is gated, so this settle never lists. + await new Promise(resolve => setTimeout(resolve, 60)) + await harness.terminal.flush() + await checkpoint('resume-sessions-loading', harness.terminal, { includeScrollback: true }) + listGate.resolve(undefined) + // With the scan released, the listing renders a tick later (the unit suite + // waits the same way); settle, then flush. await new Promise(resolve => setTimeout(resolve, 60)) await harness.terminal.flush() await checkpoint('resume-sessions', harness.terminal, { includeScrollback: true }) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 3729a1bd5b..bf7d646e24 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -616,6 +616,11 @@ describe('goodbye message and /resume', () => { }) result.terminal.send('/resume') result.terminal.send('\r') + // The loading picker owns input as soon as /resume runs, so the second + // scan starts after dismissing the first overlay, not by typing a second + // slash command over it. + result.terminal.send('\u001B') + await tick() result.terminal.send('/resume') result.terminal.send('\r') await tick() @@ -646,6 +651,37 @@ describe('goodbye message and /resume', () => { expect(result.terminal.stopped).toBeGreaterThan(0) }) + it('opens a loading picker immediately and swaps in the scanned rows', async () => { + const target = header('late-listing', 10, '/workspace') + const listing = Promise.withResolvers() + const result = await setup({ + cwd: '/workspace', + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + const readSession = () => Promise.resolve({ + session: target, + events: resumeEvents('Late listing'), + }) + ctx.provide('sessionQuery', { + listSessions: () => listing.promise, + readSession, + projectSessions: projectViaReadSession(readSession), + } as never) + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Loading sessions…') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Sessions are still loading.') + listing.resolve([{ header: target, live: false, persisted: true }]) + await tick(); await tick() + expect(result.terminal.output).toContain('Late listing') + await dispose(result) + }) + it('drops loaded selector summaries when the TUI disposed during log reads', async () => { const target = header('dispose-during-load', 10, '/workspace') const loading = Promise.withResolvers<{ meta: SessionHeader; events: SessionEvent[] }>() From a942c726f5f2551c7ff7413ee028bd879de3d292 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 18:57:24 +0800 Subject: [PATCH 18/52] fix(tui): abort the resume scan with its overlay Review findings from ds-review-bot: closing the loading picker now aborts the scan through the AbortSignal both query methods accept, a signal-ignoring backend's late settlement is dropped by a staleness check, one catch spans listing and projection so a projection failure closes the overlay instead of stranding the loading placeholder, setCandidates clears a stale still-loading error, and the batch comment no longer overstates the win as scaling with session count. --- ...resume-selector-batch-projection.i18n.yaml | 4 +- ...-07-31-resume-selector-batch-projection.md | 2 +- ...-31-resume-selector-batch-projection.zh.md | 2 +- packages/ui/tui/src/chat/resume.ts | 28 ++++-- packages/ui/tui/src/components/dialogs.ts | 2 + packages/ui/tui/tests/tui.spec.ts | 93 ++++++++++++++++++- 6 files changed, 118 insertions(+), 13 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml index 0e733bbb4d..9eb6325c04 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md -2026-07-31-resume-selector-batch-projection.md: 1120a0777bf6a6ca17dd0704d145a3c742be0695 -2026-07-31-resume-selector-batch-projection.zh.md: 79db30170765925a00be180ba2b56de8a87ba130 +2026-07-31-resume-selector-batch-projection.md: e1777d67b6f1822fd11d2d38bc6fe45ed11b179f +2026-07-31-resume-selector-batch-projection.zh.md: 031293a76afbf88c8da16f61ddef65f891c05382 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md index 1120a0777b..e1777d67b6 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md @@ -14,7 +14,7 @@ Opening the TUI `/resume` selector called `sessionQuery.readSession()` once per The `/resume` selector builds all candidate rows from one `projectSessions` batch; a rejected projection degrades to that row's disabled "Unreadable session" fallback exactly as a failed `readSession` did. `summarizeResumeCandidate` takes the borrowed source and retains only the record and derived scalars. The pre-handoff preflight still reads the single chosen session through `readSession`, keeping full replay validation before the process re-execs; its redundant live-session shortcut was dropped because `readSession` is already live-preferred. -The selector overlay opens synchronously when `/resume` dispatches, before the scan settles: an `undefined` candidate set renders a "Loading sessions…" placeholder, the picker owns terminal input from its first frame (so keystrokes during a long scan reach the search field rather than the editor), Enter reports that sessions are still loading, and Escape cancels exactly as it does on the loaded list. The finished scan swaps rows in through `setCandidates` without replacing the overlay; a queued activation behind a closing predecessor receives an already-scanned set at construction; a scan failure closes the overlay and reports the existing failure notice. +The selector overlay opens synchronously when `/resume` dispatches, before the scan settles: an `undefined` candidate set renders a "Loading sessions…" placeholder, the picker owns terminal input from its first frame (so keystrokes during a long scan reach the search field rather than the editor), Enter reports that sessions are still loading, and Escape cancels exactly as it does on the loaded list. Closing the overlay aborts the scan through the `AbortSignal` both service methods accept, so a dismissed picker does not keep decompressing a large store; a signal-ignoring backend's late settlement is dropped by a staleness check instead. The finished scan swaps rows in through `setCandidates` (which also clears a stale still-loading error) without replacing the overlay; a queued activation behind a closing predecessor receives an already-scanned set at construction; one catch spans listing and projection, so any scan failure closes the overlay and reports the existing failure notice rather than stranding the loading placeholder. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md index 79db301707..031293a76a 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md @@ -14,7 +14,7 @@ Status: implemented `/resume` 选择器通过一次 `projectSessions` 批量调用构建全部候选行;被拒绝的投影会退化为该行的禁用"Unreadable session"回退,与之前 `readSession` 失败时的行为完全一致。`summarizeResumeCandidate` 接受借用的来源,且只保留记录和推导出的标量。移交前的预检仍通过 `readSession` 读取用户选中的单个会话,在进程 re-exec 前保留完整回放验证;其中冗余的实时会话捷径被删除,因为 `readSession` 本身已是实时优先。 -选择器 overlay 在 `/resume` 分发时同步打开,早于扫描结算:`undefined` 候选集渲染"Loading sessions…"加载占位符,选择器从第一帧起就拥有终端输入(长扫描期间的按键会进入搜索字段而非编辑器),Enter 提示会话仍在加载,Escape 的取消方式与已加载列表完全相同。扫描完成后通过 `setCandidates` 换入行数据,不替换 overlay;排在正在关闭的前任之后的排队激活会在构造时直接收到已扫描的集合;扫描失败会关闭 overlay 并报告既有的失败通知。 +选择器 overlay 在 `/resume` 分发时同步打开,早于扫描结算:`undefined` 候选集渲染"Loading sessions…"加载占位符,选择器从第一帧起就拥有终端输入(长扫描期间的按键会进入搜索字段而非编辑器),Enter 提示会话仍在加载,Escape 的取消方式与已加载列表完全相同。关闭 overlay 会通过两个服务方法都接受的 `AbortSignal` 中止扫描,因此被关闭的选择器不会继续解压大型存储;忽略信号的后端在中止后的迟到结算则由过期检查丢弃。扫描完成后通过 `setCandidates`(同时清除过期的仍在加载错误)换入行数据,不替换 overlay;排在正在关闭的前任之后的排队激活会在构造时直接收到已扫描的集合;列表查询与投影共用同一个 catch,因此任何扫描失败都会关闭 overlay 并报告既有的失败通知,而不会让加载占位符悬置。 ## Alternatives considered diff --git a/packages/ui/tui/src/chat/resume.ts b/packages/ui/tui/src/chat/resume.ts index b1386ba10d..aa211f70ef 100644 --- a/packages/ui/tui/src/chat/resume.ts +++ b/packages/ui/tui/src/chat/resume.ts @@ -222,21 +222,28 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro }, }) resumeOverlay = session + // Closing the picker — Escape, supersession, disposal — aborts the scan: + // the borrowed-log pass over a large store must not outlive its overlay. + const scanAbort = new AbortController() void session.closed.then(() => { + scanAbort.abort() /* v8 ignore next -- overlay FIFO closes this session before a replacement can become the tracked resume overlay */ if (resumeOverlay === session) resumeOverlay = undefined }) deps.requestRender() - void listQuery.listSessions().then(async (records) => { - if (deps.isDisposed() || scan !== resumeScan) return + /** Whether this scan's overlay, session generation, or TUI is gone. */ + const scanStale = (): boolean => + deps.isDisposed() || scan !== resumeScan || scanAbort.signal.aborted + const scanCandidates = async (): Promise => { + const records = await listQuery.listSessions(scanAbort.signal) + if (scanStale()) return // Every workspace in the store is summarized; the picker owns the // current-workspace/all-workspaces scope split over the whole set. const providers = new Set(ctx.llm.listProviders().map(provider => provider.id)) // One bounded batch projection over borrowed logs: unlike a // per-candidate readSession, it lists persistence once and skips - // replay validation and log cloning, so opening the selector scales - // with session count instead of total log size. A corrupt neighbor - // degrades to one disabled row. + // replay validation and log cloning, bounding memory by what each + // summary retains. A corrupt neighbor degrades to one disabled row. const recordById = new Map(records.map(record => [record.header.id, record])) const listedRecord = (id: SessionId): SessionRecord => { const record = recordById.get(id) @@ -247,18 +254,23 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro const results = await listQuery.projectSessions( records.map(record => record.header.id), source => summarize(listedRecord(source.header.id), source, providers), + scanAbort.signal, ) const candidates = results.map(result => result.status === 'fulfilled' ? result.value : unreadableCandidate(listedRecord(result.sessionId), result.reason)) candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt || a.record.header.id.localeCompare(b.record.header.id)) - if (deps.isDisposed() || scan !== resumeScan) return + if (scanStale()) return scanned = candidates picker?.setCandidates(candidates) deps.requestRender() - }, (error: unknown) => { - if (deps.isDisposed() || scan !== resumeScan) return + } + // One catch covers both stages, so a projection failure cannot strand + // the overlay on its loading placeholder; an aborted scan's rejection + // stays silent because the user already dismissed the picker. + void scanCandidates().catch((error: unknown) => { + if (scanStale()) return void session.close() deps.appendNotice(`Resume session scan failed: ${errorChain(error)}`, 'error') }) diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts index 6139a94773..a5390a97b7 100644 --- a/packages/ui/tui/src/components/dialogs.ts +++ b/packages/ui/tui/src/components/dialogs.ts @@ -574,6 +574,8 @@ export class ResumePicker implements Component, Focusable { setCandidates(candidates: readonly ResumeCandidate[]): void { this.candidates = candidates this.selectedIndex = 0 + // A still-loading error is false the moment rows exist. + this.error = '' this.invalidate() } diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index bf7d646e24..8bd0e50bd6 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -42,7 +42,8 @@ import { type TuiRuntime, } from '../src/index.ts' import { WorkspaceFileSearch } from '../src/chat/file-autocomplete.ts' -import { ATTRIBUTE_ROLES, COLOR_ROLES, paletteSpec } from '../src/components/theme.ts' +import { ResumePicker } from '../src/components/dialogs.ts' +import { ATTRIBUTE_ROLES, COLOR_ROLES, createPalette, paletteSpec } from '../src/components/theme.ts' import { appendAssistant, appendUser, @@ -651,6 +652,96 @@ describe('goodbye message and /resume', () => { expect(result.terminal.stopped).toBeGreaterThan(0) }) + it('clears the still-loading error the moment scanned rows arrive', () => { + const picker = new ResumePicker( + undefined, + 10, + '/workspace', + () => 30, + createPalette(false), + () => {}, + () => {}, + ) + picker.focused = true + picker.handleInput('\r') + expect(picker.render(80).join('\n')).toContain('Sessions are still loading.') + picker.setCandidates([]) + const rendered = picker.render(80).join('\n') + expect(rendered).not.toContain('Sessions are still loading.') + expect(rendered).toContain('No matching sessions.') + }) + + it('aborts an in-flight scan when the loading picker is dismissed', async () => { + const listing = Promise.withResolvers() + let scanSignal: AbortSignal | undefined + let projections = 0 + const result = await setup({ + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.provide('sessionQuery', { + listSessions: (signal?: AbortSignal) => { scanSignal = signal; return listing.promise }, + projectSessions: async () => { projections += 1; return [] }, + } as never) + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Loading sessions…') + result.terminal.send('\u001B') + await tick() + expect(scanSignal?.aborted).toBe(true) + // A signal-ignoring backend can still fulfill after dismissal: the stale + // scan must neither project nor report. + listing.resolve([]) + await tick() + expect(projections).toBe(0) + expect(result.terminal.output).not.toContain('Resume session scan failed') + await dispose(result) + }) + + it('drops a projection that settles after the picker was dismissed', async () => { + const projecting = Promise.withResolvers() + const result = await setup({ + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.provide('sessionQuery', { + listSessions: async () => [], + projectSessions: () => projecting.promise, + } as never) + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick() + result.terminal.send('\u001B') + await tick() + projecting.resolve([]) + await tick() + expect(result.terminal.output).not.toContain('(0 of 0)') + expect(result.terminal.output).not.toContain('Resume session scan failed') + await dispose(result) + }) + + it('closes the loading picker and reports a scan that fails after listing', async () => { + const target = header('projection-explodes', 10, '/workspace') + const result = await setup({ + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.provide('sessionQuery', { + listSessions: () => Promise.resolve([{ header: target, live: false, persisted: true }]), + projectSessions: () => Promise.reject(new Error('projection exploded')), + } as never) + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('Resume session scan failed: projection exploded') + expect(result.terminal.stopped).toBe(0) + await dispose(result) + }) + it('opens a loading picker immediately and swaps in the scanned rows', async () => { const target = header('late-listing', 10, '/workspace') const listing = Promise.withResolvers() From 18700f428d5ca8f7b7b778f24815472006ca9f41 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 1 Aug 2026 18:04:38 +0800 Subject: [PATCH 19/52] feat(fs-search): spawn the packaged ripgrep binary through the subprocess seam glob/grep now run the @vscode/ripgrep binary via ctx.subprocess with a plain argv vector: no system rg install, no shell layer, unconditional registration. The load-time command -v rg probe and the bash-seam coupling are removed; timeouts ride the cooperative exec.signal plus the seam's terminate escalation. The fs-glob-sampling ACP snapshot executes the real packaged binary against an mtime-pinned fixture. Adds the packaged-ripgrep-search Agent Note, updates the roster-note facts and both shipped-composition e2es, and regenerates the doc catalogs and third-party notices (surfacing pre-existing manifest drift plus the new @vscode/ripgrep row; the notices generator also learns pnpm 11's truncated virtual-store names). --- ...26-08-01-packaged-ripgrep-search.i18n.yaml | 6 + .../2026-08-01-packaged-ripgrep-search.md | 34 + .../2026-08-01-packaged-ripgrep-search.zh.md | 34 + ...31-even-out-shipped-tool-rosters.i18n.yaml | 4 +- ...026-07-31-even-out-shipped-tool-rosters.md | 6 +- ...-07-31-even-out-shipped-tool-rosters.zh.md | 4 +- THIRD_PARTY_NOTICES.md | 9 +- apps/cli/tests/shipped-composition.e2e.ts | 12 +- apps/web/tests/shipped-composition.e2e.ts | 12 +- docs/config-catalog.md | 4 +- docs/module-graph.md | 4 +- docs/tool-catalog.md | 4 +- examples/acp-agent/tests/acp.snapshot.ts | 41 +- .../acp-agent/tests/fixtures/fs-search-bin/rg | 10 - .../snapshots/fs-glob-sampling/input.json | 2 +- .../snapshots/fs-glob-sampling/session.jsonl | 12 +- packages/fs/tool-fs-search/README.i18n.yaml | 4 +- packages/fs/tool-fs-search/README.md | 26 +- packages/fs/tool-fs-search/README.zh.md | 54 +- packages/fs/tool-fs-search/package.json | 8 +- packages/fs/tool-fs-search/src/glob.ts | 46 +- packages/fs/tool-fs-search/src/grep.ts | 36 +- packages/fs/tool-fs-search/src/index.ts | 74 +-- packages/fs/tool-fs-search/src/ripgrep.d.ts | 12 + packages/fs/tool-fs-search/src/search-core.ts | 186 +++--- packages/fs/tool-fs-search/src/shell-quote.ts | 13 +- .../tool-fs-search/tests/integration.spec.ts | 71 +- .../fs/tool-fs-search/tests/load-path.spec.ts | 56 +- .../fs/tool-fs-search/tests/tools.spec.ts | 625 ++++++++++-------- pnpm-lock.yaml | 126 +++- scripts/gen-third-party-notices.ts | 31 +- scripts/gen-tool-catalog.ts | 56 +- 32 files changed, 923 insertions(+), 699 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md create mode 100644 .agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md delete mode 100755 examples/acp-agent/tests/fixtures/fs-search-bin/rg create mode 100644 packages/fs/tool-fs-search/src/ripgrep.d.ts diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml new file mode 100644 index 0000000000..fe6bb60e68 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md +2026-08-01-packaged-ripgrep-search.md: e43354ff8e4dde0480a6c07816fc112810197234 +2026-08-01-packaged-ripgrep-search.zh.md: 55498d366a2171a178cf9d0d1004b6fe946a7281 diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md new file mode 100644 index 0000000000..e43354ff8e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md @@ -0,0 +1,34 @@ +# Agent Note: Packaged ripgrep spawn for glob/grep + +Status: implemented + +English | [中文](2026-08-01-packaged-ripgrep-search.zh.md) + +> Supersedes [bash-backed grep/glob discovery](../../archived/feature/2026-07-09-bash-backed-grep-glob-discovery.md): the v1 decision's explicitly deferred alternative — directly spawning ripgrep — is now what ships. + +## Problem + +The `glob`/`grep` tools ran through the bash executor seam, which made a system `rg` install a host dependency. On Windows and container images there is no `rg` on `PATH` by default, so the tools silently vanished there; a deployment could only discover that from the load-time probe warning. The bash seam also forced the whole model-visible argument surface through one shell-quoting helper, because a shell sat between the tool and ripgrep — the [bash-backed note](../../archived/feature/2026-07-09-bash-backed-grep-glob-discovery.md) recorded that coupling as the v1 trade-off and named direct spawn as the reasonable follow-up if the shell-string domain ever proved too sensitive. It did: every model value had to survive POSIX single-quoting, the probe had to be scripted in tests, and the executor's own timeout classification duplicated what the cooperative tool-timeout policy already owns. + +## Decision + +`@deepseek-ai/dsh-tool-fs-search` now runs the PACKAGED ripgrep binary (`@vscode/ripgrep`, an npm dependency whose optional platform packages ship the binary) through the `ctx.subprocess` seam: `runRipgrep()` spawns `rgPath` with a plain argv vector, collect-mode stdout/stderr, `graceMs`, and `exec.signal` forwarded. There is no shell layer, so the shell-quoting boundary is gone from execution; `singleQuote` stays exported as a compatibility surface with its tests. Registration is unconditional — the load-time `command -v rg` probe and the conditional registration decision are deleted, and with them the "rg not found" warning. The package injects `tools`, `systemPrompt`, and `subprocess`. + +Exit semantics stay tool-owned: exit 0 is success with results, exit 1 is a successful empty search, anything else classifies into the existing `SEARCH_*` vocabulary (invalid pattern, launch failure, signal kill, raw-output overflow). Timeout is the cooperative tool-call budget attached to the tool definitions: `@deepseek-ai/dsh-timeout-policy` aborts `exec.signal`, the subprocess seam's terminate escalation provides the hard kill, and the tool reports `SEARCH_ABORTED`. The working directory is the session header cwd when present, else `process.cwd()` — there is no executor config to default through anymore, so the tool owns the fallback. + +The `fs-glob-sampling` ACP snapshot scenario now executes the real packaged binary against a prepared workspace whose fixed mtimes pin the `--sort=modified` order, replacing the PATH-injected `rg` stand-in (POSIX-only, because the displayed paths carry `/` separators the session-log comparison cannot normalize). + +## Alternatives considered + +**Keep the bash seam and probe, but document `rg` as a required host dependency.** Rejected: the host dependency is exactly the failure this change removes, and Windows support for the discovery tools was the point of the exercise; a documented requirement is still a requirement. + +**Make `rgPath` injectable (a config field or env override) so tests and snapshots keep substituting a stand-in binary.** Rejected: it adds a public deployment surface whose only consumer would be test seams, and the real binary is deterministic enough to pin directly through fixture mtimes — the packaged binary is the deployment, so tests should exercise it. + +**Switch to a pure-JS glob/search engine (e.g. `picomatch`/`tinyglobby`).** Rejected: the [dependency-swaps audit](../../rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md) already rejected that on the "no glob engine exists" evidence; ripgrep semantics (`--sort=modified`, VCS pruning, JSON transport, regex dialect) are the tool contract. + +## Consequences + +- The discovery tools work on every platform the packaged binary covers (darwin/linux/win32, x64/arm64) with no host install; the shipped TUI/Web rosters gain `glob`/`grep` as fixed members ([even-out-shipped-tool-rosters](../feature/2026-07-31-even-out-shipped-tool-rosters.md)). +- The shell-string attack surface is gone: hostile patterns are inert argv elements, pinned by the integration suite, which now runs on Windows too (it previously self-skipped without a system `rg`). +- Load-time failure modes changed: a broken subprocess seam now fails the first search call (`SEARCH_FAILED`) instead of failing plugin load through the probe; a missing binary is a launch failure with the packaged path, not a PATH problem. +- The integration suite's fixture dropped a filename Windows cannot represent (`"` in a name), keeping the suite replayable on every platform. diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md new file mode 100644 index 0000000000..55498d366a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md @@ -0,0 +1,34 @@ +# Agent Note: glob/grep 改用打包的 ripgrep 二进制直接 spawn + +Status: implemented + +[English](2026-08-01-packaged-ripgrep-search.md) | 中文 + +> 取代 [bash 承载的 grep/glob 发现工具](../../archived/feature/2026-07-09-bash-backed-grep-glob-discovery.md):v1 决策中明确延期的方案——直接 spawn ripgrep——现在成为实际交付的实现。 + +## 问题 + +`glob`/`grep` 工具经由 bash 执行器 seam 运行,这使系统 `rg` 安装成为宿主依赖。Windows 和容器镜像的 `PATH` 默认没有 `rg`,工具在那里会静默消失;部署方只能从加载期探针警告里发现这一点。bash seam 还迫使整个模型可见参数面经过一个 shell 引号工具,因为工具与 ripgrep 之间隔着一层 shell——[bash 承载决策](../../archived/feature/2026-07-09-bash-backed-grep-glob-discovery.md) 把这种耦合记为 v1 的取舍,并把直接 spawn 列为 shell 字符串域一旦被证明过于敏感时的合理后续。它确实被证明了:每个模型值都要经受 POSIX 单引号转义,探针要在测试里脚本化,执行器自身的超时分类还与协作式工具超时策略已有的职责重复。 + +## 决策 + +`@deepseek-ai/dsh-tool-fs-search` 现在运行 PACKAGED(打包的)ripgrep 二进制(`@vscode/ripgrep`,一个 npm 依赖,其可选平台包随附二进制),经由 `ctx.subprocess` seam:`runRipgrep()` 以纯 argv 向量 spawn `rgPath`,配以 collect 模式 stdout/stderr、`graceMs` 与转发的 `exec.signal`。不再有 shell 层,执行路径上的 shell 引号边界随之消失;`singleQuote` 作为兼容导出与其测试保留。注册变为无条件——加载期 `command -v rg` 探针与条件注册决策被删除,连同那条 "rg not found" 警告。本包注入 `tools`、`systemPrompt` 与 `subprocess`。 + +退出语义仍由工具拥有:退出码 0 为有结果的成功,1 为成功的空搜索,其余归入既有 `SEARCH_*` 词汇(无效模式、启动失败、信号杀死、原始输出溢出)。超时是挂在工具定义上的协作式工具调用预算:`@deepseek-ai/dsh-timeout-policy` 中止 `exec.signal`,subprocess seam 的终止升级提供硬终止,工具报告 `SEARCH_ABORTED`。工作目录为会话 header cwd(存在时),否则为 `process.cwd()`——不再有执行器配置可供默认化,因此回退由工具自己拥有。 + +`fs-glob-sampling` ACP 快照场景改为执行真实的打包二进制,作用于一个用固定 mtime 钉住 `--sort=modified` 顺序的预制工作区,取代 PATH 注入的 `rg` 替身(仅 POSIX:展示路径携带 `/` 分隔符,会话日志比较无法归一化)。 + +## 备选方案 + +**保留 bash seam 与探针,仅把 `rg` 记为必需宿主依赖。** 否决:宿主依赖正是本次改动要消除的失败模式,而让发现工具支持 Windows 正是此举的目的;写进文档的依赖仍是依赖。 + +**让 `rgPath` 可注入(配置字段或环境变量覆盖),让测试与快照继续替换替身二进制。** 否决:这会新增一个只有测试 seam 会消费的公开部署面,而真实二进制本身足够确定——通过 fixture mtime 即可直接钉住;打包二进制就是部署形态,测试应当拿它来测。 + +**改用纯 JS 的 glob/搜索引擎(如 `picomatch`/`tinyglobby`)。** 否决:[依赖替换审计](../../rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md) 已基于"不存在 glob 引擎"的证据否决过该方向;ripgrep 语义(`--sort=modified`、VCS 剪枝、JSON 传输、正则方言)就是工具契约。 + +## 后果 + +- 发现工具在打包二进制覆盖的每个平台(darwin/linux/win32,x64/arm64)上开箱即用,无需宿主安装;交付的 TUI/Web 工具清单把 `glob`/`grep` 变为固定成员(见 [拉平交付的工具清单](../feature/2026-07-31-even-out-shipped-tool-rosters.md))。 +- shell 字符串攻击面消失:恶意模式只是惰性 argv 元素,由集成套件钉住;该套件现在也在 Windows 上运行(此前没有系统 `rg` 时它自行跳过)。 +- 加载期失败模式改变:subprocess seam 损坏现在让首次搜索调用失败(`SEARCH_FAILED`),而非通过探针使插件加载失败;二进制缺失是带打包路径的启动失败,而不是 PATH 问题。 +- 集成套件的 fixture 去掉了 Windows 无法表示的文件名(名称含 `"`),保证套件在每个平台都能重放。 diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml index 83e965b391..60be2bc25f 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md -2026-07-31-even-out-shipped-tool-rosters.md: 316e5045e559e2da162c53d64989ccecfd18b857 -2026-07-31-even-out-shipped-tool-rosters.zh.md: ed39212dc4877f4df1dc1c6e84142b61a866c548 +2026-07-31-even-out-shipped-tool-rosters.md: d5f1d714ab538b740c25bdf148df285567c616ca +2026-07-31-even-out-shipped-tool-rosters.zh.md: b09fb43ab66f3784f1a3a3f2a6ee74e185fbdad8 diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md index 316e5045e5..d5f1d714ab 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md @@ -12,7 +12,7 @@ The result was a user-visible difference nobody had decided: the same model, ask ## Decision -The rows that are not surface-specific move into [`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml), and three more join them: `tool-session-query`, `tool-str-replace-editor`, and `repeat-tool-guard`. Web search moves there too; its [deployment decision](2026-07-31-web-default-search.md) owns the security boundary while the shared base owns its surface-neutral mount. Both surfaces now assemble the same roster: twenty-five tools on every host, plus `glob` and `grep` when ripgrep is available. +The rows that are not surface-specific move into [`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml), and three more join them: `tool-session-query`, `tool-str-replace-editor`, and `repeat-tool-guard`. Web search moves there too; its [deployment decision](2026-07-31-web-default-search.md) owns the security boundary while the shared base owns its surface-neutral mount. Both surfaces now assemble the same roster: twenty-seven tools on every host — the twenty-five shared rows plus `glob` and `grep`, which are fixed members because `dsh-tool-fs-search` spawns the [packaged ripgrep binary](../architecture/2026-08-01-packaged-ripgrep-search.md). Two rows stay surface-specific. `tmux-context` is TUI-only because a browser surface has no terminal multiplexer to describe. `session-reference` is TUI-only because it drives the shared session-query index from the launcher's process-local path, and the browser sidebar reconciles that index on its own first search. @@ -46,7 +46,7 @@ The same smoke pins the TUI's unchanged execution posture from the same artifact [`apps/web/tests/shipped-composition.e2e.ts`](../../../../apps/web/tests/shipped-composition.e2e.ts) covers the Web surface in the built lane, asserting its catalog, that its access default is untouched, and that `workspace-write`'s writable roots include the temp directories — a trap that makes sandbox tests lie when the workspace sits under `/tmp` ([`roots.ts`](../../../../packages/sandbox/sandbox/src/roots.ts)). -`glob` and `grep` are asserted as an all-or-nothing pair rather than fixed members: `dsh-tool-fs-search` probes `command -v rg` at load and registers neither tool without ripgrep, which is a host dependency. +`glob` and `grep` are asserted as fixed members rather than a host-dependent pair: `dsh-tool-fs-search` spawns the packaged ripgrep binary and registers both tools unconditionally, so the pair is always present. Beyond the committed tests, both surfaces were driven against a real key from the built `apps/cli/lib/bin.js` under plain Node. Every mounted tool executed successfully, including `ralph` and `web_search`; the model never reached `cordis_*` or `mcp_*`, fell back to `grep` when asked for LSP navigation, and used a background `bash` task when asked for a persistent terminal. @@ -62,7 +62,7 @@ Beyond the committed tests, both surfaces were driven against a real key from th ## Consequences -The same model gets the same tools on both surfaces, and the difference that existed for no recorded reason is gone. The tests assert the twenty-five unconditional names exactly and require the ripgrep-dependent pair to be either present together or absent together on both sides, so a later change that alters only one surface fails a check instead of shipping quietly. +The same model gets the same tools on both surfaces, and the difference that existed for no recorded reason is gone. The tests assert all twenty-seven names exactly on both sides, so a later change that alters only one surface fails a check instead of shipping quietly. `apps/cli` gains five workspace dependencies: four the shipped tree now mounts, plus `dsh-mcp-client`, which it does not mount and which exists so an installed `dsh` can. diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md index ed39212dc4..b09fb43ab6 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -那些并非 surface 专属的行移入 [`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml),另有三行加入:`tool-session-query`、`tool-str-replace-editor` 和 `repeat-tool-guard`。Web 搜索也一并移入;其[部署决策](2026-07-31-web-default-search.md)负责安全边界,共享 base 则负责与 surface 无关的挂载。两个 surface 现在组装同一份清单:每台宿主上都有二十五个工具,ripgrep 可用时再加上 `glob` 和 `grep`。 +那些并非 surface 专属的行移入 [`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml),另有三行加入:`tool-session-query`、`tool-str-replace-editor` 和 `repeat-tool-guard`。Web 搜索也一并移入;其[部署决策](2026-07-31-web-default-search.md)负责安全边界,共享 base 则负责与 surface 无关的挂载。两个 surface 现在组装同一份清单:每台宿主上都有二十七个工具——二十五个共享行加上 `glob` 和 `grep`,它们成为固定成员是因为 `dsh-tool-fs-search` 直接 spawn [打包的 ripgrep 二进制](../architecture/2026-08-01-packaged-ripgrep-search.md)。 有两行仍是 surface 专属。`tmux-context` 只在 TUI,因为浏览器 surface 没有终端复用器可描述。`session-reference` 只在 TUI,因为它以 launcher 的进程本地路径驱动共享的 session-query 索引,而浏览器侧边栏会在自己的首次搜索里重建该索引。 @@ -62,7 +62,7 @@ Status: implemented ## 后果 -同一个模型在两个 surface 上拿到同样的工具,那处没有记录理由的差异消失了。测试会精确断言二十五个无条件提供的名称,并要求依赖 ripgrep 的一对工具在两侧要么同时存在、要么同时缺席,因此日后只改一个 surface 都会让检查失败而不是悄悄发出去。 +同一个模型在两个 surface 上拿到同样的工具,那处没有记录理由的差异消失了。测试会精确断言两侧全部二十七个名称,因此日后只改一个 surface 都会让检查失败而不是悄悄发出去。 `apps/cli` 增加五个 workspace 依赖:四个是交付树现在挂载的,外加 `dsh-mcp-client`——它并不被挂载,存在的意义是让已安装的 `dsh` 能挂。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 754ae93d82..1b14996c3f 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -47,6 +47,9 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`@opentelemetry/sdk-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | | [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT | | [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT | +| [`@testing-library/dom`](https://github.com/testing-library/dom-testing-library) | MIT | +| [`@testing-library/react`](https://github.com/testing-library/react-testing-library) | MIT | +| [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT | | [`anser`](https://github.com/IonicaBizau/anser) | MIT | | [`chokidar`](https://github.com/paulmillr/chokidar) | MIT | | [`clsx`](https://github.com/lukeed/clsx) | MIT | @@ -54,6 +57,7 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause | | [`dotenv`](https://github.com/motdotla/dotenv) | BSD-2-Clause | | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | +| [`execa`](https://github.com/sindresorhus/execa) | MIT | | [`handlebars`](https://github.com/handlebars-lang/handlebars.js) | MIT | | [`immer`](https://github.com/immerjs/immer) | MIT | | [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT | @@ -79,6 +83,7 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`turndown`](https://github.com/mixmark-io/turndown) | MIT | | [`typescript`](https://github.com/microsoft/TypeScript) | Apache-2.0 | | [`use-sync-external-store`](https://github.com/facebook/react) | MIT | +| [`vitest`](https://github.com/vitest-dev/vitest) | MIT | | [`yaml`](https://github.com/eemeli/yaml) | ISC | | [`zod`](https://github.com/colinhacks/zod) | MIT | | [`zustand`](https://github.com/pmndrs/zustand) | MIT | @@ -98,8 +103,6 @@ External packages **directly declared** only by repository tooling, test infrast | [`@modelcontextprotocol/server-everything`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 | | [`@modelcontextprotocol/server-filesystem`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 | | [`@stylistic/eslint-plugin`](https://github.com/eslint-stylistic/eslint-stylistic) | MIT | -| [`@testing-library/dom`](https://github.com/testing-library/dom-testing-library) | MIT | -| [`@testing-library/react`](https://github.com/testing-library/react-testing-library) | MIT | | [`@types/babel__code-frame`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | @@ -122,7 +125,6 @@ External packages **directly declared** only by repository tooling, test infrast | [`esbuild`](https://github.com/evanw/esbuild) | MIT | | [`eslint`](https://github.com/eslint/eslint) | MIT | | [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only | -| [`execa`](https://github.com/sindresorhus/execa) | MIT | | [`fast-check`](https://github.com/dubzzz/fast-check) | MIT | | [`jscpd`](https://github.com/kucherenko/jscpd) | MIT | | [`jsdom`](https://github.com/jsdom/jsdom) | MIT | @@ -142,7 +144,6 @@ External packages **directly declared** only by repository tooling, test infrast | [`vite-tsconfig-paths`](https://github.com/aleclarson/vite-tsconfig-paths) | MIT | | [`vitepress`](https://github.com/vuejs/vitepress) | MIT | | [`vitepress-plugin-mermaid`](https://github.com/emersonbottero/vitepress-plugin-mermaid) | MIT | -| [`vitest`](https://github.com/vitest-dev/vitest) | MIT | `eslint-plugin-sonarjs` (LGPL-3.0-only) and `lightningcss` (MPL-2.0) run only as development tooling; their code is not linked into or distributed with any DeepSeek Harness artifact. diff --git a/apps/cli/tests/shipped-composition.e2e.ts b/apps/cli/tests/shipped-composition.e2e.ts index ac1e9d5456..82bf698c17 100644 --- a/apps/cli/tests/shipped-composition.e2e.ts +++ b/apps/cli/tests/shipped-composition.e2e.ts @@ -54,10 +54,10 @@ const EXPECTED_TUI_TOOLS = [ ] /** - * `glob` and `grep` come from `dsh-tool-fs-search`, which probes `command -v rg` - * through the mounted bash executor at load and registers neither tool when - * ripgrep is absent. That is a host dependency, not a composition decision, so the - * pair is asserted separately — present together or absent together. + * `glob` and `grep` come from `dsh-tool-fs-search`, which spawns the PACKAGED + * ripgrep binary (`@vscode/ripgrep`) through the subprocess seam, so the pair + * is always present on every host — asserted as fixed members, not a host + * dependency. */ const RIPGREP_TOOLS = ['glob', 'grep'] @@ -117,7 +117,9 @@ describe('shipped dsh composition (real Loader tree in a PTY)', () => { }) expect(output).toContain(COMPOSITION_REPLY_TEXT) expect(observed?.names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TUI_TOOLS) - expect([[], RIPGREP_TOOLS]).toContainEqual(observed?.names.filter(name => RIPGREP_TOOLS.includes(name))) + // The packaged ripgrep binary ships with the dependency, so the pair is a + // fixed roster member on every host. + expect(observed?.names.filter(name => RIPGREP_TOOLS.includes(name))).toEqual(RIPGREP_TOOLS) // The TUI mounts the unrestricted local executors, so `tool-bash` emits no // escalation pair. Pinning its absence keeps a later sandbox change from // arriving here unannounced. diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 0cad833303..80adcdd1de 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -49,10 +49,10 @@ const EXPECTED_TOOLS = [ ] /** - * `glob` and `grep` come from `dsh-tool-fs-search`, which probes `command -v rg` - * through the mounted bash executor at load and registers neither tool when - * ripgrep is absent. That is a host dependency, not a composition decision, so the - * pair is asserted separately — present together or absent together. + * `glob` and `grep` come from `dsh-tool-fs-search`, which spawns the PACKAGED + * ripgrep binary (`@vscode/ripgrep`) through the subprocess seam, so the pair + * is always present on every host — asserted as fixed members, not a host + * dependency. */ const RIPGREP_TOOLS = ['glob', 'grep'] @@ -67,7 +67,9 @@ it('assembles the shipped Web catalog and keeps its access default', async () => scaffold = await launchWebScaffold() const names = scaffold.ctx.tools.schemas().map(schema => schema.name).sort() expect(names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TOOLS) - expect([[], RIPGREP_TOOLS]).toContainEqual(names.filter(name => RIPGREP_TOOLS.includes(name))) + // The packaged ripgrep binary ships with the dependency, so the pair is a + // fixed roster member on every host. + expect(names.filter(name => RIPGREP_TOOLS.includes(name))).toEqual(RIPGREP_TOOLS) // `workspace-write` is not "the workspace and nothing else": the shared roots // helper always admits the temp directories too. Pinning it against an // explicit mode keeps the claim independent of this surface's default, and diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a356a151e3..20f835bb7f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1716,7 +1716,7 @@ Source: [`packages/fs/tool-fs/src/index.ts:24`](../packages/fs/tool-fs/src/index ## `@deepseek-ai/dsh-tool-fs-search` -Requires: `tools` · `systemPrompt` · `bash` +Requires: `tools` · `systemPrompt` · `subprocess` ```ts config-catalog /** Plugin config; over-cap glob sampling is an explicit deployment choice and the remaining fields have defaults. */ @@ -1738,7 +1738,7 @@ export interface Config { } ``` -Source: [`packages/fs/tool-fs-search/src/index.ts:71`](../packages/fs/tool-fs-search/src/index.ts) +Source: [`packages/fs/tool-fs-search/src/index.ts:70`](../packages/fs/tool-fs-search/src/index.ts) ## `@deepseek-ai/dsh-tool-goal` diff --git a/docs/module-graph.md b/docs/module-graph.md index f0f58a18c1..87789c9edb 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -713,12 +713,12 @@ flowchart TD pkg_tool_fs --> pkg_system_prompt pkg_tool_fs --> pkg_tools pkg_tool_fs --> pkg_user_approval - pkg_tool_fs_search --> pkg_bash pkg_tool_fs_search --> pkg_invariants pkg_tool_fs_search --> pkg_llm pkg_tool_fs_search --> pkg_retention pkg_tool_fs_search --> pkg_session pkg_tool_fs_search --> pkg_spill + pkg_tool_fs_search --> pkg_subprocess pkg_tool_fs_search --> pkg_system_prompt pkg_tool_fs_search --> pkg_tools pkg_tool_str_replace_editor --> pkg_fs @@ -1176,7 +1176,7 @@ flowchart TD | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 7d6fa79dea..d19a7adf52 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -23,7 +23,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.pty`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. | | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after successful file operations`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | -| `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | +| `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.subprocess`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are unconditional discovery tools that spawn the packaged ripgrep binary (`@vscode/ripgrep`) through ctx.subprocess as ordinary foreground calls (never background tasks) — no host `rg` install and no shell layer. The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | | `@deepseek-ai/dsh-tool-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. | | `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `user/message goal snapshot for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. | | `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. | @@ -524,7 +524,7 @@ Search file contents with a ripgrep regular expression. Returns matching lines w Source: [`packages/fs/tool-fs-search/src/index.ts`](../packages/fs/tool-fs-search/src/index.ts) -glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. +glob and grep are unconditional discovery tools that spawn the packaged ripgrep binary (`@vscode/ripgrep`) through ctx.subprocess as ordinary foreground calls (never background tasks) — no host `rg` install and no shell layer. The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. ## `@deepseek-ai/dsh-tool-pty` diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index b652f09931..277d0b20ca 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -1,6 +1,6 @@ import { fileURLToPath } from 'node:url' import { readFileSync } from 'node:fs' -import { mkdir, writeFile } from 'node:fs/promises' +import { mkdir, utimes, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { homedir } from 'node:os' import { expect, it } from 'vitest' @@ -44,7 +44,6 @@ const SESSION_TITLE_CONFIG = fileURLToPath(new URL('../session-title.cordis.yml' const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url)) -const FS_SEARCH_BIN = fileURLToPath(new URL('./fixtures/fs-search-bin', import.meta.url)) const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny' @@ -57,6 +56,33 @@ async function prepareDelimiterPathWorkspace(cwd: string): Promise { ]) } +/** + * Seed the over-cap glob fixture: eight files under `tree/` with fixed mtimes, + * so the packaged ripgrep's `--sort=modified` order is deterministic — three + * files under `archive/`, one each under `docs/`, `src/`, and `test/`, plus + * two flat files (six top-level entries). Scoping the search to `tree/` keeps + * the harness's own session artifacts out of the listing. + */ +async function prepareFsSearchWorkspace(cwd: string): Promise { + const tree = join(cwd, 'tree') + const files: Array<[relative: string, mtime: Date]> = [ + [join('archive', 'a.ts'), new Date(2000, 0, 1, 0, 0, 0, 1)], + [join('archive', 'b.ts'), new Date(2000, 0, 1, 0, 0, 0, 2)], + [join('archive', 'c.ts'), new Date(2000, 0, 1, 0, 0, 0, 3)], + [join('docs', 'guide.md'), new Date(2000, 0, 1, 0, 0, 0, 4)], + [join('src', 'index.ts'), new Date(2000, 0, 1, 0, 0, 0, 5)], + [join('test', 'spec.ts'), new Date(2000, 0, 1, 0, 0, 0, 6)], + ['top.txt', new Date(2000, 0, 1, 0, 0, 0, 7)], + ['notes.md', new Date(2000, 0, 1, 0, 0, 0, 8)], + ] + for (const [relative, mtime] of files) { + const target = join(tree, relative) + await mkdir(dirname(target), { recursive: true }) + await writeFile(target, 'fixture\n') + await utimes(target, mtime, mtime) + } +} + // FIXME: Migrate backend-oriented scenarios to the headless stream-json suite; // this ACP suite should eventually retain only automation-protocol contracts. @@ -150,9 +176,12 @@ const SCENARIOS: Scenario[] = [ hasModelTurn: true, recorded: true, }, - // The real Loader/app/bash path executes a deterministic rg stand-in at the - // external-process seam, pinning over-cap glob sampling without depending on - // a host-installed ripgrep binary. + // The real Loader/app/subprocess path executes the PACKAGED ripgrep binary + // against a prepared workspace whose fixed mtimes pin the + // `--sort=modified` order, pinning over-cap glob sampling without depending + // on a host-installed ripgrep binary or a PATH stand-in. POSIX-only because + // the displayed paths carry `/` separators the session-log comparison + // cannot normalize. { name: 'fs-glob-sampling', hasModelTurn: true, @@ -160,7 +189,7 @@ const SCENARIOS: Scenario[] = [ pinsHeader: true, headerClass: 'fs-search', configPath: FS_SEARCH_CONFIG, - env: { PATH: `${FS_SEARCH_BIN}:${process.env.PATH ?? ''}` }, + prepareWorkspace: prepareFsSearchWorkspace, posixOnly: true, }, { name: 'fs-read', hasModelTurn: true, recorded: true }, diff --git a/examples/acp-agent/tests/fixtures/fs-search-bin/rg b/examples/acp-agent/tests/fixtures/fs-search-bin/rg deleted file mode 100755 index 181ad68837..0000000000 --- a/examples/acp-agent/tests/fixtures/fs-search-bin/rg +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/sh -printf '%s\n' \ - 'archive/a.ts' \ - 'archive/b.ts' \ - 'archive/c.ts' \ - 'old\one' \ - 'old\two' \ - 'src/index.ts' \ - 'docs/guide.md' \ - 'test/spec.ts' diff --git a/examples/acp-agent/tests/snapshots/fs-glob-sampling/input.json b/examples/acp-agent/tests/snapshots/fs-glob-sampling/input.json index cc5fc95e59..d615bd4840 100644 --- a/examples/acp-agent/tests/snapshots/fs-glob-sampling/input.json +++ b/examples/acp-agent/tests/snapshots/fs-glob-sampling/input.json @@ -2,6 +2,6 @@ "steps": [ { "op": "initialize" }, { "op": "newSession" }, - { "op": "prompt", "text": "Call glob exactly once with pattern * and no path. Then reply with exactly GLOB_SAMPLED and nothing else." } + { "op": "prompt", "text": "Call glob exactly once with pattern * and path tree. Then reply with exactly GLOB_SAMPLED and nothing else." } ] } diff --git a/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl b/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl index b66ffc36b4..ca51259632 100644 --- a/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl @@ -1,18 +1,18 @@ {"type":"session","version":0,"id":"f5a99d52-3eaa-4ce7-858d-61d4fd77df2a","createdAt":1785218400000,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1785218400001,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1785218400002,"data":{"content":[{"type":"text","text":"Call glob exactly once with pattern * and no path. Then reply with exactly GLOB_SAMPLED and nothing else."}],"source":{"kind":"user"},"role":"user","id":"6790985f-1de2-42f8-a7f1-24e46d6439c7"},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1785218400002,"data":{"content":[{"type":"text","text":"Call glob exactly once with pattern * and path tree. Then reply with exactly GLOB_SAMPLED and nothing else."}],"source":{"kind":"user"},"role":"user","id":"6790985f-1de2-42f8-a7f1-24e46d6439c7"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785218400003,"data":{"title":"Call glob exactly once with","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1785218400004,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1785218400005,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":5,"time":1785483397569,"data":{"provider":"deepseek","model":"deepseek-v4-pro"}} {"type":"assistant/chunk","seq":6,"time":1785218400007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":7,"time":1785218400008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"glob-sampling-call","name":"glob","argumentsDelta":"{\"pattern\":\"*\"}"}}} -{"type":"assistant/chunk","seq":8,"time":1785218400009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\"}"}}}} +{"type":"assistant/chunk","seq":7,"time":1785218400008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"glob-sampling-call","name":"glob","argumentsDelta":"{\"pattern\":\"*\",\"path\":\"tree\"}"}}} +{"type":"assistant/chunk","seq":8,"time":1785218400009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\",\"path\":\"tree\"}"}}}} {"type":"assistant/chunk","seq":9,"time":1785218400010,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}} {"type":"assistant/chunk","seq":10,"time":1785483397579,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":11,"time":1785483397579,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-pro"},"id":"a127cfe5-39fb-462c-8e5a-a8c79bd0e52b"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[6,7,8,9,10],"surfaceOp":"append"} -{"type":"tool/call","seq":12,"time":1785483397579,"data":{"turn":1,"step":1,"callId":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\"}"}} -{"type":"tool/result","seq":13,"time":1785483398062,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"glob-sampling-call"},"content":[{"type":"tool-result","toolCallId":"glob-sampling-call","content":[{"type":"text","text":"archive/a.ts\nold\\one\nold\\two\nsrc/index.ts\n\n(Showing 4 of 8 paths, sampled across 4 of the 6 top-level entries this pattern matched instead of taken in modification-time order. Narrow path to inspect a specific subtree. The complete result could not be saved; narrow pattern or path to see more.)"}],"isError":false}],"role":"user","id":"2beecb2e-627d-43dc-a936-03e1dc874093"},"meta":{"shape":"paths","paths":["archive/a.ts","old\\one","old\\two","src/index.ts"],"truncated":true,"total":8}},"sourceEventSeqs":[12],"surfaceOp":"append"} +{"type":"assistant/message","seq":11,"time":1785483397579,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\",\"path\":\"tree\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-pro"},"id":"a127cfe5-39fb-462c-8e5a-a8c79bd0e52b"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[6,7,8,9,10],"surfaceOp":"append"} +{"type":"tool/call","seq":12,"time":1785483397579,"data":{"turn":1,"step":1,"callId":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\",\"path\":\"tree\"}"}} +{"type":"tool/result","seq":13,"time":1785483398062,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"glob-sampling-call"},"content":[{"type":"tool-result","toolCallId":"glob-sampling-call","content":[{"type":"text","text":"tree/archive/a.ts\ntree/docs/guide.md\ntree/src/index.ts\ntree/test/spec.ts\n\n(Showing 4 of 8 paths, sampled across 4 of the 6 top-level entries this pattern matched instead of taken in modification-time order. Narrow path to inspect a specific subtree. The complete result could not be saved; narrow pattern or path to see more.)"}],"isError":false}],"role":"user","id":"2beecb2e-627d-43dc-a936-03e1dc874093"},"meta":{"shape":"paths","paths":["tree/archive/a.ts","tree/docs/guide.md","tree/src/index.ts","tree/test/spec.ts"],"truncated":true,"total":8}},"sourceEventSeqs":[12],"surfaceOp":"append"} {"type":"step/end","seq":14,"time":1785483398062,"data":{"turn":1,"step":1}} {"type":"step/start","seq":15,"time":1785483398072,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":16,"time":1785218400017,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/packages/fs/tool-fs-search/README.i18n.yaml b/packages/fs/tool-fs-search/README.i18n.yaml index bedb8289c1..5ed7bfb8bb 100644 --- a/packages/fs/tool-fs-search/README.i18n.yaml +++ b/packages/fs/tool-fs-search/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/fs/tool-fs-search/README.md -README.md: b12ffda9869c7d6bef5ea5b54594781ecf555ff4 -README.zh.md: 7dd6cdf9a209f2fe357b4ffe48d20d574266ce60 +README.md: 0152be017ae15fc83a3d5cb7df927f25d04d2d53 +README.zh.md: 69ce49f3ad1621021dc1d0938cdc07a900d1cda8 diff --git a/packages/fs/tool-fs-search/README.md b/packages/fs/tool-fs-search/README.md index b12ffda986..0152be017a 100644 --- a/packages/fs/tool-fs-search/README.md +++ b/packages/fs/tool-fs-search/README.md @@ -2,21 +2,21 @@ English | [中文](README.zh.md) -The **model-facing filesystem discovery tools**—`glob`, `grep`—are backed by the **bash executor seam**, not by `ctx.fs` provider methods. At load, the package probes `command -v rg` through `ctx.bash`; if the executor cannot find ripgrep on its `PATH`, it logs a warning and registers no tools or prompt sections. Each call assembles a fixed ripgrep command (every model-controlled value through one package-private shell-quoting helper), runs it via `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` as an ordinary foreground tool call, parses the raw `rg` output, and returns a workdir-relative canonical value. The package injects `tools`, `systemPrompt`, and `bash`—deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional. +The **model-facing filesystem discovery tools**—`glob`, `grep`—are backed by the **packaged ripgrep binary** (`@vscode/ripgrep`), not by `ctx.fs` provider methods and not by a system `rg` install. Registration is unconditional: the binary ships inside the npm dependency, so there is no load-time availability probe. Each call spawns the binary through the `ctx.subprocess` seam with a fixed argv vector (model-controlled values are plain argv elements — no shell layer exists, so no quoting applies), parses the raw `rg` output, and returns a workdir-relative canonical value. The package injects `tools`, `systemPrompt`, and `subprocess`—deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional. ```ts ignore-check // A deployment chooses how over-cap glob pages are selected. -await ctx.plugin(LocalBashExecutor, { cwd: process.cwd() }) // @deepseek-ai/dsh-bash-local +await ctx.plugin(LocalSubprocessService) // @deepseek-ai/dsh-subprocess-local await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: false }) // Optional: a spill backend makes capped results fully recoverable. await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-spill-local ``` -Why bash-backed: local workspace discovery is naturally a process-backed `rg` workflow, and putting search on `ctx.fs` would force every filesystem backend to grow a search API. The bash executor owns request defaulting/capping, subprocess execution, process-group termination, environment scrubbing, raw output capture, and backend substitution (local, sandboxed, remote); this package owns schemas, argument validation, shell quoting, parsing, retention, formatted-result spill, and timeout declaration. The tools never call `ctx.bash.start()` and never expose a bash task id — the call returns only after `rg` exits, times out, is aborted, or fails. +Why spawn-backed: local workspace discovery is naturally a process-backed `rg` workflow, and putting search on `ctx.fs` would force every filesystem backend to grow a search API. The subprocess seam owns spawn execution, process-tree termination, environment scrubbing, and bounded output capture; this package owns schemas, argument validation, argv construction, parsing, retention, formatted-result spill, and timeout declaration. The tools never expose a background task — the call returns only after `rg` exits, is terminated by the cooperative timeout, is aborted, or fails. -## Deployment requirement: rg + co-located bash/filesystem +## Deployment requirement: no host rg, co-located workdir/filesystem -The mounted bash executor must be able to resolve `rg` from its `PATH` at plugin load; otherwise `glob` and `grep` are absent from the model-visible tool schema. Returned paths are displayed relative to the resolved bash workdir (the calling agent's session cwd when present, else the executor's configured default) and are follow-up-readable with `read` only when the bash workdir and the filesystem root are the same workspace. v1 documents that co-location requirement and performs no runtime cross-service validation; remote or virtual filesystem search waits for a shared workspace contract or a provider-specific search backend. +The binary ships with the package on every supported platform (macOS/Linux/Windows, x64/arm64), so no host `rg` install is required and the tools register on every deployment. Returned paths are displayed relative to the resolved workdir (the calling agent's session cwd when present, else `process.cwd()`) and are follow-up-readable with `read` only when that workdir and the filesystem root are the same workspace. v1 documents that co-location requirement and performs no runtime cross-service validation; remote or virtual filesystem search waits for a shared workspace contract or a provider-specific search backend. ## Config @@ -29,24 +29,24 @@ The mounted bash executor must be able to resolve `rg` from its `PATH` at plugin | `grepMaxMatches` | `250` | Max flat matches one `grep` call retains inline (matches Claude Code's `GrepTool` `head_limit`); later matches go to the formatted spill artifact. | | `grepMaxLineBytes` | `2000` | Byte cap per matched-line preview; the cut preserves UTF-8 boundaries and is marked `(line truncated)`. | | `rawOutputMaxBytes` | `20000000` | Max complete raw `rg` stdout a search will parse (matches Claude Code's ripgrep raw buffer); larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. | -| `timeoutMs` | `30000` | Cooperative tool-call budget attached to both tool definitions, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`; the bash backend's own timeout stays a second safety cap. | +| `timeoutMs` | `30000` | Cooperative tool-call budget attached to both tool definitions, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`; the subprocess seam's terminate escalation is the hard kill. | ## Tools | Tool | Arguments | Behavior | |---|---|---| -| `glob` | `pattern`, `path?` | `rg --files --glob --sort=modified --no-ignore --hidden` plus VCS metadata excludes (`.git`, `.svn`, `.hg`, `.bzr`, `.jj`, `.sl`). `path` is an optional **directory** search root; omitted means the resolved bash workdir. Returns one FILE path per line; `rg --files` never emits directory entries. The pattern keeps ripgrep semantics: without a `/` it matches the basename at any depth, so `*` matches the whole tree. Complete results stay modification-time ordered; over-cap presentation follows `sampleOverCapGlobResults`. | +| `glob` | `pattern`, `path?` | `rg --files --glob --sort=modified --no-ignore --hidden` plus VCS metadata excludes (`.git`, `.svn`, `.hg`, `.bzr`, `.jj`, `.sl`). `path` is an optional **directory** search root; omitted means the resolved workdir. Returns one FILE path per line; `rg --files` never emits directory entries. The pattern keeps ripgrep semantics: without a `/` it matches the basename at any depth, so `*` matches the whole tree. Complete results stay modification-time ordered; over-cap presentation follows `sampleOverCapGlobResults`. | | `grep` | `pattern`, `path?`, `include?` | Line-oriented `rg --json` parse (no colon-splitting ambiguity). `pattern` is a ripgrep regex; `path` is an optional **file or directory** target; `include` is ONE positive glob filter — a comma-separated list or a negated (`!…`) value is rejected up front (brace alternation like `*.{ts,tsx}` is fine). Returns matches grouped by file as `Line N: `. | Routine budgets stay out of the model-facing schema (no `head_limit`/`offset`/`case_insensitive`/output modes): a model that needs surrounding context reads the matched file with `read`; one that needs later results follows the returned spill locator's retrieval hint. ## Two budgets, two artifacts -Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam and parses only complete retained stdout; if the executor still returns `stdout.truncated`, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. A successful `glob` keeps the displayed search root and every acquired path in `{ root, paths }`; when sampling is enabled, `root` lets the Native renderer group an explicit relative or absolute search path by entries beneath that root rather than by its workdir prefix. `grep` keeps every acquired `{ path, lineNumber, line }` in `{ matches }`. Inline item and per-line preview caps apply only in the Native renderer. For a direct surface call with more logical results than the inline cap, post-policy best-effort saves the complete formatted preview through `ctx.spillStore.saveText()` and replaces only presentation with the configured page plus locator. Nested Code dispatches skip that spill because their full canonical value does not enter model context. Missing/failed spill keeps the inline page and reports that the complete result could not be saved—never an `isError`. +Raw `rg` stdout is an internal transport detail. Each search requests a collect-mode stdout budget of `rawOutputMaxBytes` from the subprocess seam and parses only complete retained stdout; if the seam still reports a lossy read, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. A successful `glob` keeps the displayed search root and every acquired path in `{ root, paths }`; when sampling is enabled, `root` lets the Native renderer group an explicit relative or absolute search path by entries beneath that root rather than by its workdir prefix. `grep` keeps every acquired `{ path, lineNumber, line }` in `{ matches }`. Inline item and per-line preview caps apply only in the Native renderer. For a direct surface call with more logical results than the inline cap, post-policy best-effort saves the complete formatted preview through `ctx.spillStore.saveText()` and replaces only presentation with the configured page plus locator. Nested Code dispatches skip that spill because their full canonical value does not enter model context. Missing/failed spill keeps the inline page and reports that the complete result could not be saved—never an `isError`. ## Errors -Search failures carry the package-owned `SearchError` (a `HarnessError` subclass), surfaced as `{ name, code }` on `isError` results: `SEARCH_INVALID_PATTERN` (ripgrep rejected the regex/glob), `SEARCH_FAILED` (runtime `rg` disappearance after registration, inaccessible target, signal kill, malformed `--json` output), `SEARCH_RAW_OUTPUT_OVERFLOW` (raw output over `rawOutputMaxBytes`, or still truncated after the requested stdout capture budget), and `SEARCH_ABORTED` (tool timeout, caller cancellation, or the bash executor's own timeout). ripgrep exit semantics are tool-owned: exit 0 is success with results, exit 1 is a successful empty search (`No files found` / `No matches found`), and only other exits are failures. Model argument mistakes (blank pattern, a list-valued `include`) stay ordinary tool argument errors. +Search failures carry the package-owned `SearchError` (a `HarnessError` subclass), surfaced as `{ name, code }` on `isError` results: `SEARCH_INVALID_PATTERN` (ripgrep rejected the regex/glob), `SEARCH_FAILED` (a failed `rg` launch, inaccessible target, signal kill, malformed `--json` output), `SEARCH_RAW_OUTPUT_OVERFLOW` (raw output over `rawOutputMaxBytes`, or still lossy after the requested stdout capture budget), and `SEARCH_ABORTED` (cooperative tool timeout or caller cancellation). ripgrep exit semantics are tool-owned: exit 0 is success with results, exit 1 is a successful empty search (`No files found` / `No matches found`), and only other exits are failures. Model argument mistakes (blank pattern, a list-valued `include`) stay ordinary tool argument errors. ## Model Experience @@ -54,7 +54,7 @@ Search failures carry the package-owned `SearchError` (a `HarnessError` subclass #### What the model sees -After the load-time `rg` probe succeeds, every request in this plugin's registration scope contains the independently registered glob and grep guidance below. Agent-scoped tool restrictions can hide either schema without removing its prompt section. +Every request in this plugin's registration scope contains the independently registered glob and grep guidance below. Agent-scoped tool restrictions can hide either schema without removing its prompt section. ##### Glob guidance with `sampleOverCapGlobResults: true` @@ -86,7 +86,7 @@ Prefix-stable while the plugin scope, sampling choice, and guidance text are unc #### What the model sees -The glob description states the configured over-cap ordering. The generated [`glob` and `grep` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) use `sampleOverCapGlobResults: true`; schemas are visible only after the load-time `rg` probe succeeds. +The glob description states the configured over-cap ordering. The generated [`glob` and `grep` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) use `sampleOverCapGlobResults: true`; the tools are registered unconditionally. #### Token effect @@ -126,7 +126,7 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **Search and file access have no shared-workspace proof** — returned paths are follow-up-readable only when the bash workdir and filesystem root denote the same workspace; the package performs no runtime cross-service validation. -- **Ripgrep is a deployment dependency** — a missing `rg` executable makes the package register no tools or guidance; an incompatible executable or one that disappears after registration fails calls with `SEARCH_FAILED`. Remote or virtual filesystems need a co-located executor or another search consumer. +- **Search and file access have no shared-workspace proof** — returned paths are follow-up-readable only when the workdir and filesystem root denote the same workspace; the package performs no runtime cross-service validation. +- **The packaged binary is fixed at dependency version** — `@vscode/ripgrep` covers the platforms it ships (macOS/Linux/Windows, x64/arm64); an unsupported platform or a corrupted install fails calls with `SEARCH_FAILED`. Remote or virtual filesystems need a co-located workspace or another search consumer. - **The schemas expose one bounded page** — offset pagination, case-mode switches, alternate output modes, and provider-backed discovery remain outside this package; capped complete output requires a spill backend. - **Sampling, when enabled, groups by first path segment beneath the search root only** — an over-cap `glob` page balances across those top-level entries, so a result concentrated deeper (one busy directory inside an otherwise even tree) is still shown unevenly below that level; recursive balancing is deferred. diff --git a/packages/fs/tool-fs-search/README.zh.md b/packages/fs/tool-fs-search/README.zh.md index 7dd6cdf9a2..69ce49f3ad 100644 --- a/packages/fs/tool-fs-search/README.zh.md +++ b/packages/fs/tool-fs-search/README.zh.md @@ -2,21 +2,21 @@ [English](README.md) | 中文 -**面向模型的文件系统发现工具**(`glob`、`grep`)由 **bash 执行器 seam** 支持,而不是由 `ctx.fs` 提供方方法支持。加载时,本包(package)探测 `command -v rg`,探测通过 `ctx.bash` 进行;如果执行器无法在其 `PATH` 上找到 ripgrep,就记录警告,并且不注册工具或提示词段。每次调用都会组装固定的 ripgrep 命令(所有模型控制的值都经过同一个包私有 shell 引用辅助函数),通过 `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` 作为普通前台工具调用运行,解析原始 `rg` 输出,并返回相对于工作目录的规范值。本包注入 `tools`、`systemPrompt` 和 `bash`,有意**不**注入 `fs`;格式化结果 spill 为可选功能,因此机会性读取 `ctx.spillStore`,调用方式为 `ctx.get()`。 +**面向模型的文件系统发现工具**(`glob`、`grep`)由 **打包的 ripgrep 二进制**(`@vscode/ripgrep`)支持,而不是由 `ctx.fs` 提供方方法或系统 `rg` 安装支持。注册是无条件的:二进制随 npm 依赖一起交付,因此没有加载期可用性探针。每次调用都通过 `ctx.subprocess` seam 以固定 argv 向量 spawn 该二进制(模型控制的值是普通 argv 元素——不存在 shell 层,因此无需引号),解析原始 `rg` 输出,并返回相对于工作目录的规范值。本包注入 `tools`、`systemPrompt` 和 `subprocess`,有意**不**注入 `fs`;格式化结果 spill 为可选功能,因此机会性读取 `ctx.spillStore`,调用方式为 `ctx.get()`。 ```ts ignore-check // A deployment chooses how over-cap glob pages are selected. -await ctx.plugin(LocalBashExecutor, { cwd: process.cwd() }) // @deepseek-ai/dsh-bash-local +await ctx.plugin(LocalSubprocessService) // @deepseek-ai/dsh-subprocess-local await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: false }) // Optional: a spill backend makes capped results fully recoverable. await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-spill-local ``` -采用 bash 支持的原因:本地工作区发现天然是由进程支持的 `rg` 工作流;如果把搜索放到 `ctx.fs` 上,就会迫使每个文件系统后端扩展搜索 API。bash 执行器负责请求默认值/上限、子进程执行、进程组终止、环境清理、原始输出捕获和后端替换(本地、沙箱化、远程);本包负责 schema、参数校验、shell 引用、解析、保留、格式化结果 spill 和超时声明。工具绝不调用 `ctx.bash.start()`,也不公开 bash task id;只有在 `rg` 退出、超时、中止或失败后,调用才会返回。 +采用 spawn 支持的原因:本地工作区发现天然是由进程支持的 `rg` 工作流;如果把搜索放到 `ctx.fs` 上,就会迫使每个文件系统后端扩展搜索 API。subprocess seam 负责 spawn 执行、进程树终止、环境清理和有界输出捕获;本包负责 schema、参数校验、argv 构造、解析、保留、格式化结果 spill 和超时声明。工具绝不暴露后台任务——只有在 `rg` 退出、被协作式超时终止、被中止或失败后,调用才会返回。 -## 部署要求:rg 与共置的 bash/文件系统 +## 部署要求:无需宿主 rg,但工作目录与文件系统需共置 -已挂载的 bash 执行器必须能在插件加载时解析 `rg`,其来源是执行器的 `PATH`;否则面向模型的工具 schema 中不会出现 `glob` 和 `grep`。返回路径会相对于解析后的 bash 工作目录显示(调用方 agent(智能体)有会话 cwd 时使用该 cwd,否则使用执行器配置的默认值);只有 bash 工作目录与文件系统根目录是同一工作区时,才能用 `read` 继续读取。v1 只记录这项共置要求,不执行运行时跨服务校验;远程或虚拟文件系统搜索需等待共享工作区契约或特定提供方的搜索后端。 +二进制随包交付,覆盖所有受支持平台(macOS/Linux/Windows,x64/arm64),因此无需宿主 `rg` 安装,工具在每个部署上都注册。返回路径会相对于解析后的工作目录显示(调用方 agent(智能体)有会话 cwd 时使用该 cwd,否则使用 `process.cwd()`);只有该工作目录与文件系统根目录是同一工作区时,才能用 `read` 继续读取。v1 只记录这项共置要求,不执行运行时跨服务校验;远程或虚拟文件系统搜索需等待共享工作区契约或特定提供方的搜索后端。 ## 配置 @@ -29,24 +29,24 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh- | `grepMaxMatches` | `250` | 一次 `grep` 调用内联保留的最大平铺匹配数(与 Claude Code 的 `GrepTool` `head_limit` 相同);后续匹配写入格式化 spill 产物。 | | `grepMaxLineBytes` | `2000` | 每条匹配行预览的字节上限;截断会保留 UTF-8 边界,并标记为 `(line truncated)`。 | | `rawOutputMaxBytes` | `20000000` | 搜索将解析的完整原始 `rg` stdout 上限(与 Claude Code 的 ripgrep 原始 buffer 相同);更大的原始输出以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败。 | -| `timeoutMs` | `30000` | 附加到两个工具定义上的协作式工具调用预算,由 `@deepseek-ai/dsh-timeout-policy` 通过 `exec.signal` 强制执行;bash 后端自身的超时仍作为第二道安全上限。 | +| `timeoutMs` | `30000` | 附加到两个工具定义上的协作式工具调用预算,由 `@deepseek-ai/dsh-timeout-policy` 通过 `exec.signal` 强制执行;subprocess seam 的终止升级提供硬终止。 | ## 工具 | 工具 | 参数 | 行为 | |---|---|---| -| `glob` | `pattern`、`path?` | 运行 `rg --files --glob --sort=modified --no-ignore --hidden`,并排除 VCS 元数据(`.git`、`.svn`、`.hg`、`.bzr`、`.jj`、`.sl`)。`path` 是可选的**目录**搜索根;省略时使用解析后的 bash 工作目录。每行返回一个**文件**路径;`rg --files` 从不输出目录条目。pattern 保留 ripgrep 语义:不含 `/` 时匹配任意深度的基名,因此 `*` 匹配整棵树。完整结果保持按修改时间排序;超过上限时的呈现方式遵循 `sampleOverCapGlobResults`。 | +| `glob` | `pattern`、`path?` | 运行 `rg --files --glob --sort=modified --no-ignore --hidden`,并排除 VCS 元数据(`.git`、`.svn`、`.hg`、`.bzr`、`.jj`、`.sl`)。`path` 是可选的**目录**搜索根;省略时使用解析后的工作目录。每行返回一个**文件**路径;`rg --files` 从不输出目录条目。pattern 保留 ripgrep 语义:不含 `/` 时匹配任意深度的基名,因此 `*` 匹配整棵树。完整结果保持按修改时间排序;超过上限时的呈现方式遵循 `sampleOverCapGlobResults`。 | | `grep` | `pattern`、`path?`、`include?` | 按行解析 `rg --json`,避免按冒号拆分的歧义。`pattern` 是 ripgrep 正则表达式;`path` 是可选的**文件或目录**目标;`include` 是一个正向 glob 过滤器,前置拒绝逗号分隔列表或否定值(`!…`),但允许 `*.{ts,tsx}` 等花括号交替。返回按文件分组、形如 `Line N: ` 的匹配。 | 常规预算不进入面向模型的 schema(没有 `head_limit`/`offset`/`case_insensitive`/输出模式):模型需要周边上下文时,用 `read` 读取匹配文件;需要后续结果时,遵循返回的 spill locator 检索提示。 ## 两类预算、两类产物 -原始 `rg` stdout 是内部传输细节。每次搜索从 bash seam 请求 `stdoutMaxBytes: rawOutputMaxBytes`,且只解析完整保留的 stdout;如果执行器仍返回 `stdout.truncated`,搜索会以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败,并要求模型缩小查询。成功的 `glob` 在 `{ root, paths }` 中保留所显示的搜索根及所有已取得路径;启用采样时,借助 `root`,原生渲染器能以显式的相对或绝对搜索路径为根,按该根下的条目分组,而不是按其工作目录前缀分组。`grep` 保留所有已取得的 `{ path, lineNumber, line }`,并将其存入 `{ matches }`。内联条目和每行预览上限只应用于原生渲染器。直接接口调用的逻辑结果超过内联上限时,后置策略会尽力通过 `ctx.spillStore.saveText()` 保存完整格式化预览,并只把呈现替换为配置指定的页面与 locator。嵌套 Code 分派会跳过 spill,因为其完整规范值不会进入模型上下文。spill 缺失/失败时保留内联页面,并报告完整结果无法保存,绝不会成为 `isError`。 +原始 `rg` stdout 是内部传输细节。每次搜索从 subprocess seam 请求 `rawOutputMaxBytes` 的 collect 模式 stdout 预算,且只解析完整保留的 stdout;如果 seam 仍报告 lossy 读取,搜索会以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败,并要求模型缩小查询。成功的 `glob` 在 `{ root, paths }` 中保留所显示的搜索根及所有已取得路径;启用采样时,借助 `root`,原生渲染器能以显式的相对或绝对搜索路径为根,按该根下的条目分组,而不是按其工作目录前缀分组。`grep` 保留所有已取得的 `{ path, lineNumber, line }`,并将其存入 `{ matches }`。内联条目和每行预览上限只应用于原生渲染器。直接接口调用的逻辑结果超过内联上限时,后置策略会尽力通过 `ctx.spillStore.saveText()` 保存完整格式化预览,并只把呈现替换为配置指定的页面与 locator。嵌套 Code 分派会跳过 spill,因为其完整规范值不会进入模型上下文。spill 缺失/失败时保留内联页面,并报告完整结果无法保存,绝不会成为 `isError`。 ## 错误 -搜索失败携带本包拥有的 `SearchError`(`HarnessError` 子类),以 `{ name, code }` 公开在 `isError` 结果上:`SEARCH_INVALID_PATTERN`(ripgrep 拒绝正则/glob)、`SEARCH_FAILED`(注册后 `rg` 在运行时消失、目标不可访问、信号终止、`--json` 输出格式错误)、`SEARCH_RAW_OUTPUT_OVERFLOW`(原始输出超过 `rawOutputMaxBytes`,或在请求 stdout 捕获预算后仍被截断)和 `SEARCH_ABORTED`(工具超时、调用方取消或 bash 执行器自身超时)。ripgrep 退出语义由工具拥有:退出 0 表示成功且有结果,退出 1 表示成功的空搜索(`No files found` / `No matches found`),只有其他退出值表示失败。模型参数错误(空白 pattern、列表值 `include`)仍是普通工具参数错误。 +搜索失败携带本包拥有的 `SearchError`(`HarnessError` 子类),以 `{ name, code }` 公开在 `isError` 结果上:`SEARCH_INVALID_PATTERN`(ripgrep 拒绝正则/glob)、`SEARCH_FAILED`(`rg` 启动失败、目标不可访问、信号终止、`--json` 输出格式错误)、`SEARCH_RAW_OUTPUT_OVERFLOW`(原始输出超过 `rawOutputMaxBytes`,或在请求 stdout 捕获预算后仍 lossy)和 `SEARCH_ABORTED`(协作式工具超时或调用方取消)。ripgrep 退出语义由工具拥有:退出 0 表示成功且有结果,退出 1 表示成功的空搜索(`No files found` / `No matches found`),只有其他退出值表示失败。模型参数错误(空白 pattern、列表值 `include`)仍是普通工具参数错误。 ## 模型体验 @@ -54,7 +54,7 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh- #### 模型看到的内容 -加载时 `rg` 探测成功后,该插件注册作用域内的每个请求都包含下方独立注册的 glob 与 grep 指导。agent 作用域的工具限制可以隐藏任一 schema,而不移除其提示词段。 +该插件注册作用域内的每个请求都包含下方独立注册的 glob 与 grep 指导。agent 作用域的工具限制可以隐藏任一 schema,而不移除其提示词段。 ##### 启用 `sampleOverCapGlobResults: true` 时的 Glob 指导 @@ -76,57 +76,57 @@ Use the grep tool — not shell grep or rg — to search file contents. Use read #### Token 影响 -工具注册期间,每个请求支付固定指导成本;必填的采样选项决定采用哪个 glob 变体。 +工具注册期间每个请求有固定的指导成本;必填的采样选择决定采用哪一个 glob 变体。 #### KV Cache 影响 -只要插件作用域、采样选项和指导文本不变,前缀就保持稳定。启用、dispose(资源释放)或更改该选项,可能从该提示词段开始使复用失效。 +插件作用域、采样选择与指导文本不变时前缀稳定。激活、销毁或改变选择可能使该提示词段的复用失效。 ### 工具 schema #### 模型看到的内容 -glob 描述会说明配置所指定的超限结果排序方式。已生成的 [`glob` 和 `grep` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) 使用 `sampleOverCapGlobResults: true`;只有加载时 `rg` 探测成功后,这些 schema 才可见。 +glob 描述声明了配置的超过上限排序方式。生成的 [`glob` 和 `grep` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) 使用 `sampleOverCapGlobResults: true`;工具无条件注册。 #### Token 影响 -工具可见的每个请求都支付固定 schema 成本。 +工具可见时每个请求有固定的 schema 成本。 #### KV Cache 影响 -只要工具可见性和定义不变,前缀就保持稳定。注册生命周期或作用域限制可能从首个变化的 schema token 开始使复用失效。 +工具可见性与定义不变时前缀稳定。注册生命周期或作用域限制可能从第一个改变的 schema token 起使复用失效。 -### 结果与 spill 通知 +### 结果与 spill 提示 #### 模型看到的内容 -`glob` 每行返回一个路径;`grep` 在每个路径下对 `Line : ` 匹配分组。空搜索返回 `No files found` 或 `No matches found`。达到上限的结果末尾会附加省略数量、spill locator 和后端检索提示,或说明完整结果无法保存。`sampleOverCapGlobResults: true` 时,超过上限的 `glob` 页面会在实际搜索根正下方的条目之间按轮转方式取路径,footer 会说明采样依据和触达的顶层条目数;若无法触达全部条目,footer 会要求模型缩小 `path`。设为 `false` 时,页面保留按修改时间排序的前部,并沿用通常用于达到上限结果的 footer。未超过上限的结果原样不动;扁平的采样结果也沿用普通 footer,因为其样本等同于按修改时间排序的前部。spill 产物始终保存按修改时间排序的完整列表。 +`glob` 每行返回一个路径;`grep` 在每个路径下分组展示 `Line : ` 匹配。空搜索返回 `No files found` 或 `No matches found`。达到上限的结果以省略计数结尾,并附 spill locator 与后端检索提示;否则说明完整结果无法保存。启用 `sampleOverCapGlobResults: true` 时,超过上限的 `glob` 页面按实际搜索根正下方的条目轮转取路径,页脚说明采样依据及其覆盖的顶层条目数;无法覆盖全部条目时,页脚提示模型收窄 `path`。`false` 时页面是按修改时间排序的前部,并保留普通的上限结果页脚。未超过上限的结果原样呈现;扁平采样的结果也保留普通页脚,因为其采样等于按修改时间排序的前部。spill 产物始终持有按修改时间排序的完整列表。 #### Token 影响 -内联路径和匹配受 `globMaxResults`、`grepMaxMatches` 与 `grepMaxLineBytes` 限制;调用和保留结果会留在历史中,直到上下文压缩(compaction)。 +内联路径与匹配受 `globMaxResults`、`grepMaxMatches` 与 `grepMaxLineBytes` 约束;调用与保留结果在压缩前留在历史中。 #### KV Cache 影响 -仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +只追加;新可见内容跟在可复用请求前缀之后,不会使既有 KV-cache 条目失效。 ### 工具错误 #### 模型看到的内容 -失败会规范化为 `Error: `,并向调用方提供结构化的 `SEARCH_INVALID_PATTERN`、`SEARCH_FAILED`、`SEARCH_RAW_OUTPUT_OVERFLOW` 或 `SEARCH_ABORTED` 元数据。 +失败被规范化为 `Error: `,并携带结构化 `SEARCH_INVALID_PATTERN`、`SEARCH_FAILED`、`SEARCH_RAW_OUTPUT_OVERFLOW` 或 `SEARCH_ABORTED` 元数据供调用方使用。 #### Token 影响 -只有失败调用会添加这些保留 token。 +只有失败的调用会增加这些保留 token。 #### KV Cache 影响 -仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 +只追加;新可见内容跟在可复用请求前缀之后,不会使既有 KV-cache 条目失效。 -## 已知限制与暂缓事项 +## 已知局限与延期工作 -- **搜索和文件访问没有共享工作区证明**:只有 bash 工作目录和文件系统根目录表示同一工作区时,返回路径才能继续读取;本包不执行运行时跨服务校验。 -- **Ripgrep 是部署依赖**:缺失 `rg` 可执行文件时,本包不注册工具或指导;可执行文件不兼容或注册后消失时,调用以 `SEARCH_FAILED` 失败。远程或虚拟文件系统需要共置执行器或其他搜索消费方。 -- **schema 只公开一个有界页面**:offset 分页、大小写模式开关、其他输出模式和提供方支持的发现均不在本包内;达到上限的完整输出需要 spill 后端。 -- **启用采样时,只按搜索根下的路径首段分组**:超过上限的 `glob` 页面在这些顶层条目之间做均衡,因此集中在更深层的结果(一棵总体均匀的树里某个特别庞大的子目录)在该层级以下仍然分布不均;递归均衡已延期。 +- **搜索与文件访问没有共享工作区证明**——只有当工作目录与文件系统根目录指向同一工作区时,返回路径才保证可继续读取;本包不执行运行时跨服务校验。 +- **打包二进制固定在依赖版本上**——`@vscode/ripgrep` 覆盖其随附的平台(macOS/Linux/Windows,x64/arm64);不支持的平台或损坏的安装会以 `SEARCH_FAILED` 使调用失败。远程或虚拟文件系统需要共置的工作区或另一个搜索消费方。 +- **schema 只暴露一个有界页面**——偏移分页、大小写开关、替代输出模式与提供方支撑的发现仍不在本包范围内;达到上限的完整输出需要 spill 后端。 +- **启用采样时仅按搜索根正下方的第一段路径分组**——超过上限的 `glob` 页面在这些顶层条目之间平衡,因此集中在更深处的结果(一棵均匀树里某个繁忙目录)在该层级之下仍会呈现不均;递归平衡被延期。 diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index bf9cf15aa0..8953aea77a 100644 --- a/packages/fs/tool-fs-search/package.json +++ b/packages/fs/tool-fs-search/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-tool-fs-search", - "description": "Model-facing filesystem discovery tools (glob, grep) backed by the DeepSeek Harness bash seam (ctx.bash)", + "description": "Model-facing filesystem discovery tools (glob, grep) backed by the packaged ripgrep binary (@vscode/ripgrep)", "version": "0.0.1", "private": true, "type": "module", @@ -27,23 +27,23 @@ ], "license": "BSD-3-Clause", "dependencies": { + "@vscode/ripgrep": "^1.18.0", "schemastery": "^3.18.0" }, "peerDependencies": { - "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-retention": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-spill": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-bash": "workspace:^", - "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/fs/tool-fs-search/src/glob.ts b/packages/fs/tool-fs-search/src/glob.ts index 2670ab57c4..6e7d411a01 100644 --- a/packages/fs/tool-fs-search/src/glob.ts +++ b/packages/fs/tool-fs-search/src/glob.ts @@ -1,10 +1,11 @@ /** * The model-facing `glob` tool: discover files whose paths match a glob - * pattern, sorted by modification time. Execution goes through the bash seam - * (`ctx.bash`) with a fixed `rg --files` command — this module owns the - * model-facing schema, argument validation, shell-safe command construction, - * result parsing, inline sampling, and formatting; process concerns (defaulting, - * scrubbing, kill, backend substitution) stay behind `ctx.bash`. + * pattern, sorted by modification time. Execution spawns the packaged + * ripgrep binary (`@vscode/ripgrep`) directly through the subprocess seam + * with a plain argv vector — this module owns the model-facing schema, + * argument validation, argv construction, result parsing, inline sampling, + * and formatting; process concerns (spawn execution, tree termination, + * environment scrubbing, output capture) stay behind `ctx.subprocess`. * @module @deepseek-ai/dsh-tool-fs-search/glob */ @@ -13,11 +14,9 @@ import { sep } from 'node:path' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools' import type { SpillRef } from '@deepseek-ai/dsh-spill' -import type {} from '@deepseek-ai/dsh-bash' import type {} from '@deepseek-ai/dsh-system-prompt' import { runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts' import { globSearchMeta, searchViewFromMeta } from './presentation.ts' -import { singleQuote } from './shell-quote.ts' import { acceptedSurfaceValue } from './surface.ts' /** @@ -73,32 +72,35 @@ export function parseGlobArgs(args: { pattern: string; path?: string }): GlobInp } /** - * Build the fixed `rg --files` command for one `glob` call. Every + * Build the fixed `rg --files` argv for one `glob` call. Every * model-controlled value ({@link GlobInput.pattern}, {@link GlobInput.path}) - * passes through {@link singleQuote}; the search root rides behind `--` so a - * leading-dash path can never be parsed as a flag. `--sort=modified` orders by - * modification time, `--no-ignore --hidden` searches ignored and hidden files, - * and {@link GLOB_VCS_EXCLUDES} keeps VCS metadata out. + * is a plain argv element — no shell layer exists, so no quoting applies; the + * search root rides behind `--` so a leading-dash path can never be parsed as + * a flag. `--sort=modified` orders by modification time, `--no-ignore + * --hidden` searches ignored and hidden files, and + * {@link GLOB_VCS_EXCLUDES} keeps VCS metadata out. * * @param input - the validated arguments. - * @returns the complete, shell-safe command string. + * @returns the complete ripgrep argument vector (excluding the binary itself). */ -export function buildGlobCommand(input: GlobInput): string { +export function buildGlobCommand(input: GlobInput): string[] { const parts = [ - 'rg --files', - `--glob=${singleQuote(input.pattern)}`, - '--sort=modified --no-ignore --hidden', + '--files', + `--glob=${input.pattern}`, + '--sort=modified', + '--no-ignore', + '--hidden', // Two negated globs per VCS name: the bare form prunes the directory // during traversal; the /** form still excludes the contents when the // search root is AT or INSIDE the directory (where the bare form, // matched against root-prefixed paths, never fires). ...GLOB_VCS_EXCLUDES.flatMap(name => [ - `--glob=${singleQuote(`!**/${name}`)}`, - `--glob=${singleQuote(`!**/${name}/**`)}`, + `--glob=!**/${name}`, + `--glob=!**/${name}/**`, ]), ] - if (input.path !== undefined) parts.push('--', singleQuote(input.path)) - return parts.join(' ') + if (input.path !== undefined) parts.push('--', input.path) + return parts } /** @@ -285,7 +287,7 @@ export function presentGlobResult(_args: { pattern: string; path?: string }, res * Register the `glob` tool and its system-prompt guidance. * * @param ctx - the plugin context; registrations are effects scoped to it, and - * execution uses its `bash` service. + * execution uses its `subprocess` service. * @param caps - the deployment's resolved glob caps (plugin config after defaulting). */ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void { diff --git a/packages/fs/tool-fs-search/src/grep.ts b/packages/fs/tool-fs-search/src/grep.ts index b7e67ea153..03b49f01e8 100644 --- a/packages/fs/tool-fs-search/src/grep.ts +++ b/packages/fs/tool-fs-search/src/grep.ts @@ -1,11 +1,12 @@ /** * The model-facing `grep` tool: search file contents with a ripgrep regular - * expression. Execution goes through the bash seam (`ctx.bash`) with a fixed - * line-oriented `rg --json` command so file path, line number, and line text - * parse without colon-splitting ambiguity — this module owns the model-facing - * schema, argument validation, shell-safe command construction, `--json` - * record parsing, per-line preview retention, match retention, grouping, and - * formatting; process concerns stay behind `ctx.bash`. + * expression. Execution spawns the packaged ripgrep binary + * (`@vscode/ripgrep`) directly through the subprocess seam with a plain argv + * vector using a fixed line-oriented `rg --json` command so file path, line + * number, and line text parse without colon-splitting ambiguity — this module + * owns the model-facing schema, argument validation, argv construction, + * `--json` record parsing, per-line preview retention, match retention, + * grouping, and formatting; process concerns stay behind `ctx.subprocess`. * * @module @deepseek-ai/dsh-tool-fs-search/grep */ @@ -15,12 +16,10 @@ import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools' import type { RetainedItems } from '@deepseek-ai/dsh-retention' import type { SpillRef } from '@deepseek-ai/dsh-spill' -import type {} from '@deepseek-ai/dsh-bash' import type {} from '@deepseek-ai/dsh-system-prompt' import type { GrepMatch } from './search-core.ts' import { SearchError, previewLine, retainGrepMatches, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts' import { grepSearchMeta, searchViewFromMeta } from './presentation.ts' -import { singleQuote } from './shell-quote.ts' import { acceptedSurfaceValue } from './surface.ts' /** @@ -96,20 +95,21 @@ export function parseGrepArgs(args: { pattern: string; path?: string; include?: } /** - * Build the fixed line-oriented `rg --json` command for one `grep` call. Every + * Build the fixed line-oriented `rg --json` argv for one `grep` call. Every * model-controlled value ({@link GrepInput.pattern}, {@link GrepInput.path}, - * {@link GrepInput.include}) passes through {@link singleQuote}; the pattern - * and include ride in `--flag=value` form and the target behind `--`, so a - * leading-dash value can never be parsed as a flag. + * {@link GrepInput.include}) is a plain argv element — no shell layer exists, + * so no quoting applies; the pattern and include ride in `--flag=value` form + * and the target behind `--`, so a leading-dash value can never be parsed as + * a flag. * * @param input - the validated arguments. - * @returns the complete, shell-safe command string. + * @returns the complete ripgrep argument vector (excluding the binary itself). */ -export function buildGrepCommand(input: GrepInput): string { - const parts = ['rg --json', `--regexp=${singleQuote(input.pattern)}`] - if (input.include !== undefined) parts.push(`--glob=${singleQuote(input.include)}`) - if (input.path !== undefined) parts.push('--', singleQuote(input.path)) - return parts.join(' ') +export function buildGrepCommand(input: GrepInput): string[] { + const parts = ['--json', `--regexp=${input.pattern}`] + if (input.include !== undefined) parts.push(`--glob=${input.include}`) + if (input.path !== undefined) parts.push('--', input.path) + return parts } /** diff --git a/packages/fs/tool-fs-search/src/index.ts b/packages/fs/tool-fs-search/src/index.ts index 072865d568..e596ae3ca1 100644 --- a/packages/fs/tool-fs-search/src/index.ts +++ b/packages/fs/tool-fs-search/src/index.ts @@ -1,28 +1,27 @@ /** * The model-facing filesystem discovery tool suite (`glob`, `grep`) over the - * bash executor seam (`ctx.bash`). This single plugin registers both tools - * only when the mounted bash executor can find `rg` on its `PATH`. + * packaged ripgrep binary (`@vscode/ripgrep`). This single plugin registers + * both tools; the binary ships inside the npm dependency, so no system `rg` + * install and no shell layer is involved. * - * ## Bash-backed, not a `ctx.fs` provider method + * ## Spawn-backed, not a `ctx.fs` provider method * * Local workspace discovery is a process-backed `rg` workflow, so these tools - * execute through `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` with fixed - * ripgrep command templates — never `ctx.bash.start()`, never a model-visible - * background task. The tool layer owns schemas, argument validation, shell - * quoting ({@link module:@deepseek-ai/dsh-tool-fs-search/shell-quote}), result - * parsing, retention, formatted-result spill, and timeout declaration; the - * bash executor owns request defaulting/capping, subprocess execution, - * process-group termination, environment scrubbing, raw output capture, and - * backend substitution. At load, the package probes `command -v rg` through the - * same bash seam; if ripgrep is absent, `glob` / `grep` and their prompt - * sections are not registered. The package injects `tools`, `systemPrompt`, - * and `bash` — deliberately NOT `fs`, and `ctx.spillStore` is read + * execute through `ctx.subprocess.spawn()` with fixed ripgrep argv templates — + * never `ctx.bash`, never `ctx.bash.start()`, never a model-visible background + * task. The tool layer owns schemas, argument validation, argv construction + * ({@link module:@deepseek-ai/dsh-tool-fs-search/glob} / + * {@link module:@deepseek-ai/dsh-tool-fs-search/grep}), result parsing, + * retention, formatted-result spill, and timeout declaration; the subprocess + * seam owns spawn execution, process-tree termination, environment scrubbing, + * and raw output capture. The package injects `tools`, `systemPrompt`, and + * `subprocess` — deliberately NOT `fs`, and `ctx.spillStore` is read * opportunistically with `ctx.get()` because formatted-result spill is optional. * - * Returned paths are displayed relative to the resolved bash workdir and are - * follow-up-readable only in co-located deployments where the bash workdir and - * the filesystem `read` root are the same workspace — a documented v1 - * deployment requirement, not runtime-validated. + * Returned paths are displayed relative to the resolved workdir and are + * follow-up-readable only in co-located deployments where the workdir and the + * filesystem `read` root are the same workspace — a documented v1 deployment + * requirement, not runtime-validated. * * @module @deepseek-ai/dsh-tool-fs-search */ @@ -65,7 +64,7 @@ export { singleQuote } from './shell-quote.ts' export const name = 'tool-fs-search' /** Services required by the search tool suite (`spillStore` is optional, read via `ctx.get()`). */ -export const inject = ['tools', 'systemPrompt', 'bash'] +export const inject = ['tools', 'systemPrompt', 'subprocess'] /** Plugin config; over-cap glob sampling is an explicit deployment choice and the remaining fields have defaults. */ export interface Config { @@ -98,9 +97,6 @@ export const Config: z = z.object({ /** The shape after schemastery applied the defaults. */ type ResolvedConfig = Required -/** POSIX-shell builtin probe for the ripgrep binary in the bash executor environment. */ -const RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1' - /** Every search cap counts items/bytes/milliseconds — a positive integer, or retention and timeout arithmetic misbehaves silently. */ function assertPositiveInteger(name: string, value: number): void { if (!Number.isInteger(value) || value < 1) { @@ -109,36 +105,14 @@ function assertPositiveInteger(name: string, value: number): void { } /** - * Check whether the mounted bash executor can find `rg`. - * - * Nonzero exit means "not available" and disables this optional tool suite. - * Infrastructure failures stay loud: a deployment with a broken bash executor - * should not silently lose tools in a way that looks like a deliberate skip. - * - * @param ctx - plugin context whose `bash` service is the executor the tools will use. - * @returns true when `command -v rg` exits 0, false when it exits nonzero. - */ -async function ripgrepAvailable(ctx: Context): Promise { - const spec = ctx.bash.resolve({ command: RG_PROBE_COMMAND }) - let result - try { - result = await ctx.bash.run(spec) - } catch (error: unknown) { - throw new Error(`tool-fs-search: ripgrep availability probe could not start: ${String(error)}`, { cause: error }) - } - if (result.aborted || result.timedOut || result.signal !== null || result.exitCode === null) { - throw new Error('tool-fs-search: ripgrep availability probe did not complete') - } - return result.exitCode === 0 -} - -/** - * Register the `glob`/`grep` filesystem discovery tool suite when `rg` exists. + * Register the `glob`/`grep` filesystem discovery tool suite. The packaged + * ripgrep binary is always available (an npm dependency), so registration is + * unconditional. * * @param ctx - plugin context; registrations are effects scoped to this plugin. * @param config - resolved plugin configuration from schemastery. - * @returns when ripgrep is unavailable, resolves without registering any tools. */ +// oxlint-disable-next-line typescript/require-await -- async keeps a load-time config rejection a rejection, not a synchronous throw export async function apply(ctx: Context, config: Config): Promise { // schemastery (Config) has already filled every defaulted field. const resolved = config as ResolvedConfig @@ -148,10 +122,6 @@ export async function apply(ctx: Context, config: Config): Promise { assertPositiveInteger('searchMetaMaxBytes', resolved.searchMetaMaxBytes) assertPositiveInteger('rawOutputMaxBytes', resolved.rawOutputMaxBytes) assertPositiveInteger('timeoutMs', resolved.timeoutMs) - if (!await ripgrepAvailable(ctx)) { - ctx.logger.warn('tool-fs-search: ripgrep (rg) not found on the bash executor PATH; glob/grep tools not registered') - return - } applyGlobTool(ctx, { sampleOverCapGlobResults: resolved.sampleOverCapGlobResults, maxResults: resolved.globMaxResults, diff --git a/packages/fs/tool-fs-search/src/ripgrep.d.ts b/packages/fs/tool-fs-search/src/ripgrep.d.ts new file mode 100644 index 0000000000..25d268471e --- /dev/null +++ b/packages/fs/tool-fs-search/src/ripgrep.d.ts @@ -0,0 +1,12 @@ +/** + * Minimal type surface for the `@vscode/ripgrep` package: an ESM module that + * resolves the platform ripgrep binary (`@vscode/ripgrep--` + * optional dependency) and exports its absolute path as the named export + * `rgPath` (no bundled type declarations). + * @module @deepseek-ai/dsh-tool-fs-search/ripgrep-types + */ + +declare module '@vscode/ripgrep' { + /** Absolute path to the packaged ripgrep executable for the current platform. */ + export const rgPath: string +} diff --git a/packages/fs/tool-fs-search/src/search-core.ts b/packages/fs/tool-fs-search/src/search-core.ts index 402fc9d655..c9f5f80b5b 100644 --- a/packages/fs/tool-fs-search/src/search-core.ts +++ b/packages/fs/tool-fs-search/src/search-core.ts @@ -1,16 +1,19 @@ /** * Shared execution plumbing for the `glob` / `grep` search tools: the - * package-owned `SEARCH_*` error vocabulary, one bash-seam run helper that - * turns a fixed `rg` command into complete raw stdout, the best-effort - * formatted-result spill handoff, and workdir-relative path display. + * package-owned `SEARCH_*` error vocabulary, one spawn helper that runs the + * PACKAGED ripgrep binary (`@vscode/ripgrep`) with a plain argv vector and + * returns complete raw stdout, the best-effort formatted-result spill handoff, + * and workdir-relative path display. * - * Both tools execute through `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` - * as ordinary foreground tool calls — never `ctx.bash.start()`, never a - * model-visible background task. Raw `rg` stdout is an internal transport - * detail: the tools request a per-run stdout capture budget from the bash seam, - * parse only complete in-memory stdout within `rawOutputMaxBytes`, and never - * read executor spill files. The model-facing recovery artifact is the - * formatted result saved through `ctx.spillStore.saveText()` + * Both tools execute as ordinary foreground spawns through `ctx.subprocess` — + * never `ctx.bash`, never `ctx.bash.start()`, never a model-visible background + * task. The ripgrep binary ships inside the npm package, so no system `rg` + * install is required, and no shell layer exists between the argv vector and + * ripgrep, so no shell quoting is involved. Raw `rg` stdout is an internal + * transport detail: the tools request a per-run stdout capture budget from the + * subprocess seam, parse only complete in-memory stdout within + * `rawOutputMaxBytes`, and never read spill files. The model-facing recovery + * artifact is the formatted result saved through `ctx.spillStore.saveText()` * ({@link trySaveFormattedResult}). * * @module @deepseek-ai/dsh-tool-fs-search/search-core @@ -18,10 +21,11 @@ import { isAbsolute, relative, sep } from 'node:path' import type { Context } from 'cordis' +import { rgPath } from '@vscode/ripgrep' import { HarnessError } from '@deepseek-ai/dsh-llm' import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-retention' import type { RetainedItems } from '@deepseek-ai/dsh-retention' -import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' +import type { SubprocessCollect, SubprocessOutcome, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill' import type { ToolExecution } from '@deepseek-ai/dsh-tools' @@ -38,6 +42,18 @@ export const RAW_OUTPUT_MAX_BYTES = 20_000_000 */ export const SEARCH_TIMEOUT_MS = 30_000 +/** + * Default cap in bytes on the retained stderr tail of one search run — a + * diagnostic excerpt only (the tool never reads `stderr.spillPath`). + */ +const SEARCH_STDERR_MAX_BYTES = 64 * 1024 + +/** Default whole-stream spill cap for search output (the subprocess seam requires an explicit budget). */ +const SEARCH_SPILL_MAX_BYTES = 64 * 1024 * 1024 + +/** Default terminate grace period for a search process (ms). */ +const SEARCH_GRACE_MS = 3_000 + /** * Default cap in bytes on one search's serialized `presentationMeta` (the * `searchMetaMaxBytes` config). The inline match/path caps already bound the item @@ -52,14 +68,14 @@ export const SEARCH_META_MAX_BYTES = 65_536 /** * Stable, machine-routable codes for search failures. Package-owned (not - * `FsErrorCode`) because these tools are bash-backed discovery, not `ctx.fs` + * `FsErrorCode`) because these tools are spawn-backed discovery, not `ctx.fs` * provider operations: `SEARCH_INVALID_PATTERN` — ripgrep rejected the regex or * glob; `SEARCH_FAILED` — the search could not run or its output could not be - * parsed (missing `rg`, inaccessible target, signal kill, malformed `--json`); - * `SEARCH_RAW_OUTPUT_OVERFLOW` — raw `rg` output exceeded `rawOutputMaxBytes` - * or stayed truncated after that requested stdout budget; `SEARCH_ABORTED` — the tool - * timeout, caller cancellation, or the bash executor's own timeout cut the - * search short. + * parsed (a failed `rg` launch, inaccessible target, signal kill, malformed + * `--json`); `SEARCH_RAW_OUTPUT_OVERFLOW` — raw `rg` output exceeded + * `rawOutputMaxBytes` or stayed truncated after that requested stdout budget; + * `SEARCH_ABORTED` — the cooperative tool timeout or caller cancellation cut + * the search short. */ export type SearchErrorCode = | 'SEARCH_INVALID_PATTERN' @@ -84,7 +100,7 @@ export class SearchError extends HarnessError { /** The completed acquisition of one `rg` run: complete stdout plus the resolved workdir. */ export interface RipgrepRun { - /** Complete raw stdout retained by the bash executor within the requested cap. */ + /** Complete raw stdout retained by the subprocess seam within the requested cap. */ stdout: string /** True when ripgrep exited 1: a successful search with zero results. */ noMatches: boolean @@ -94,73 +110,71 @@ export interface RipgrepRun { /** * The retained stderr tail as a diagnostic excerpt, with a truncation note when - * the executor dropped bytes (the tool never reads `stderr.spillPath`). + * the subprocess seam dropped bytes (the tool never reads `stderr.spillPath`). */ -function stderrExcerpt(stderr: CollectedOutput): string { - const text = stderr.text.trim() +function stderrExcerpt(stderrText: string, truncated: boolean): string { + const text = stderrText.trim() if (text.length === 0) return '' - return stderr.truncated ? `${text} [stderr truncated]` : text + return truncated ? `${text} [stderr truncated]` : text } /** Classify a nonzero-exit `rg` run into the search error vocabulary (invalid pattern vs missing `rg` vs everything else). */ -function classifyRunFailure(toolName: string, result: BashRunResult): SearchError { - const stderr = stderrExcerpt(result.stderr) +function classifyRunFailure(toolName: string, exitCode: number, stderrText: string, stderrTruncated: boolean): SearchError { + const stderr = stderrExcerpt(stderrText, stderrTruncated) if (/regex parse error|error parsing glob/i.test(stderr)) { return new SearchError(`${toolName} pattern rejected by ripgrep: ${stderr}`, 'SEARCH_INVALID_PATTERN') } - if (result.exitCode === 127 || /command not found/i.test(stderr)) { - return new SearchError(`${toolName} requires ripgrep (rg) on the bash executor's PATH${stderr.length > 0 ? `: ${stderr}` : ''}`, 'SEARCH_FAILED') + if (exitCode === 127 || /command not found/i.test(stderr)) { + return new SearchError(`${toolName} requires ripgrep (rg) to launch${stderr.length > 0 ? `: ${stderr}` : ''}`, 'SEARCH_FAILED') } - return new SearchError(`${toolName} search failed (exit ${result.exitCode})${stderr.length > 0 ? `: ${stderr}` : ''}`, 'SEARCH_FAILED') + return new SearchError(`${toolName} search failed (exit ${exitCode})${stderr.length > 0 ? `: ${stderr}` : ''}`, 'SEARCH_FAILED') } /** * Acquire the COMPLETE raw stdout of a finished run, enforcing * `rawOutputMaxBytes` on the in-memory transport. A truncated result means the - * bash backend could not retain complete stdout within the requested budget, so - * the tool fails clearly instead of parsing a silently-partial stream. + * subprocess seam could not retain complete stdout within the requested + * budget, so the tool fails clearly instead of parsing a silently-partial + * stream. */ -function completeStdout(toolName: string, result: BashRunResult, rawOutputMaxBytes: number): string { +function completeStdout(toolName: string, stdout: SubprocessOutputRead, rawOutputMaxBytes: number): string { const narrow = 'narrow pattern, path, or include and retry' - if (!result.stdout.truncated) { - const inlineBytes = Buffer.byteLength(result.stdout.text, 'utf8') + if (!stdout.lossy) { + const inlineBytes = Buffer.byteLength(stdout.text, 'utf8') if (inlineBytes > rawOutputMaxBytes) { throw new SearchError( `${toolName} produced ${inlineBytes} bytes of raw output, over the ${rawOutputMaxBytes}-byte cap; ${narrow}`, 'SEARCH_RAW_OUTPUT_OVERFLOW', ) } - return result.stdout.text + return stdout.text } throw new SearchError( - `${toolName} produced more raw output than the bash executor retained within the ${rawOutputMaxBytes}-byte cap; ${narrow}`, + `${toolName} produced more raw output than the subprocess seam retained within the ${rawOutputMaxBytes}-byte cap; ${narrow}`, 'SEARCH_RAW_OUTPUT_OVERFLOW', ) } /** - * Run one fixed `rg` command through the bash seam and return its complete raw - * stdout. The bash request workdir is the calling agent's session cwd - * (`exec.agent.session.header.cwd`) when available — mirroring `dsh-tool-bash` / - * `dsh-tool-fs` — else omitted so the implementation's `resolve()` applies its - * configured default. `exec.signal` is forwarded so the cooperative tool - * timeout (`@deepseek-ai/dsh-timeout-policy`) and caller cancellation kill the - * command; the bash backend's own timeout stays a second safety cap. + * Run the packaged ripgrep binary with a plain argv vector and return its + * complete raw stdout. The working directory is the calling agent's session + * cwd (`exec.agent.session.header.cwd`) when available, else + * `process.cwd()`. `exec.signal` is forwarded so the cooperative tool timeout + * (`@deepseek-ai/dsh-timeout-policy`) and caller cancellation terminate the + * process tree. * * Exit semantics are tool-owned: exit 0 is success with results, exit 1 is * success with zero results (`noMatches`), anything else throws a * {@link SearchError} (abort/timeout → `SEARCH_ABORTED`, invalid pattern → * `SEARCH_INVALID_PATTERN`, the rest → `SEARCH_FAILED` / - * `SEARCH_RAW_OUTPUT_OVERFLOW`). A `run()` REJECTION — the seam's - * infrastructure failures (pre-aborted signal, unusable workdir, missing - * shell) — is translated into the same taxonomy: a pre-aborted signal becomes - * `SEARCH_ABORTED`, everything else `SEARCH_FAILED`, with the original as - * `cause`. + * `SEARCH_RAW_OUTPUT_OVERFLOW`). A spawn REJECTION — the seam's + * infrastructure failures — is translated into `SEARCH_FAILED` with the + * original as `cause`; a pre-aborted signal becomes `SEARCH_ABORTED`. * - * @param ctx - the plugin context; execution uses its `bash` service. + * @param ctx - the plugin context; execution uses its `subprocess` service. * @param exec - the tool-execution context; supplies the session cwd and the abort signal. * @param toolName - `glob` or `grep`, used in error messages. - * @param command - the fully-quoted `rg` command string (every model value already through `singleQuote`). + * @param argv - the ripgrep arguments (every model value an unquoted argv element; no shell layer exists). * @param rawOutputMaxBytes - cap on the complete raw stdout the tool will parse. * @returns the complete stdout, the zero-result flag, and the resolved workdir. */ @@ -168,54 +182,64 @@ export async function runRipgrep( ctx: Context, exec: ToolExecution, toolName: string, - command: string, + argv: readonly string[], rawOutputMaxBytes: number, ): Promise { - const cwd = exec.agent?.session.header.cwd - const spec = ctx.bash.resolve({ - command, - stdoutMaxBytes: rawOutputMaxBytes, - ...cwd !== undefined ? { workdir: cwd } : {}, - signal: exec.signal, - }) - let result: BashRunResult - try { - result = await ctx.bash.run(spec) - } catch (error: unknown) { - // The seam contract: run() REJECTS only for infrastructure failures — a - // pre-aborted signal, an unusable workdir, a missing shell. Translate them - // so these failures stay machine-routable under the SEARCH_* taxonomy. - if (spec.signal?.aborted === true) { - throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED', { cause: error }) - } - throw new SearchError(`${toolName} could not start its search command (unusable working directory or missing shell)`, 'SEARCH_FAILED', { cause: error }) - } - if (result.aborted) { + if (exec.signal.aborted) { throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED') } - if (result.timedOut) { - throw new SearchError(`${toolName} timed out after ${result.timeoutMs}ms in the bash executor; narrow pattern, path, or include and retry`, 'SEARCH_ABORTED') + const cwd = exec.agent?.session.header.cwd + const workdir = cwd ?? process.cwd() + const collect = (maxBytes: number): SubprocessCollect => + ({ maxBytes, spill: { maxBytes: SEARCH_SPILL_MAX_BYTES } }) + const handle = ctx.subprocess.spawn({ + argv: [rgPath, ...argv], + cwd: workdir, + stdio: { + stdin: 'ignore', + stdout: collect(rawOutputMaxBytes), + stderr: collect(SEARCH_STDERR_MAX_BYTES), + }, + graceMs: SEARCH_GRACE_MS, + signal: exec.signal, + } satisfies SubprocessSpawnSpec) + let outcome: SubprocessOutcome + try { + outcome = await handle.done + } catch (error: unknown) { + throw new SearchError(`${toolName} could not start its search command (ripgrep launch failed)`, 'SEARCH_FAILED', { cause: error }) } - if (result.signal !== null || result.exitCode === null) { - throw new SearchError(`${toolName} search command was killed by signal ${result.signal ?? '(unknown)'}`, 'SEARCH_FAILED') + const stdout = handle.collected.stdout?.readFrom(0) + const stderr = handle.collected.stderr?.readFrom(0) + if (stdout === undefined || stderr === undefined) { + throw new SearchError(`${toolName} search command produced no collected output streams`, 'SEARCH_FAILED') } - if (result.exitCode !== 0 && result.exitCode !== 1) { - throw classifyRunFailure(toolName, result) + // The signal can abort while the spawn is awaited; the static narrowing that + // proves this re-check "always false" cannot see AbortSignal state changes. + // oxlint-disable-next-line typescript/no-unnecessary-condition + if (exec.signal.aborted) { + throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED') } - const stdout = completeStdout(toolName, result, rawOutputMaxBytes) - return { stdout, noMatches: result.exitCode === 1, workdir: spec.workdir } + if (outcome.signal !== null || outcome.exitCode === null) { + throw new SearchError(`${toolName} search command was killed by signal ${outcome.signal ?? '(unknown)'}`, 'SEARCH_FAILED') + } + if (outcome.exitCode !== 0 && outcome.exitCode !== 1) { + throw classifyRunFailure(toolName, outcome.exitCode, stderr.text, stderr.lossy) + } + const text = completeStdout(toolName, stdout, rawOutputMaxBytes) + return { stdout: text, noMatches: outcome.exitCode === 1, workdir } } /** * Map an `rg` output path to its display form: absolute paths inside the - * resolved bash workdir become workdir-relative; everything else (relative - * output, paths outside the workdir) passes through unchanged. Display-only — - * returned paths are follow-up-readable in co-located bash/filesystem + * resolved workdir become workdir-relative; everything else (relative output, + * paths outside the workdir) passes through unchanged. Display-only — + * returned paths are follow-up-readable in co-located workdir/filesystem * deployments where both resolve the same workspace (the documented v1 * deployment requirement). * * @param path - one path as ripgrep printed it. - * @param workdir - the resolved bash workdir the command ran in. + * @param workdir - the resolved workdir the command ran in. * @returns the workdir-relative display path when possible, else `path` unchanged. */ export function toWorkdirRelative(path: string, workdir: string): string { diff --git a/packages/fs/tool-fs-search/src/shell-quote.ts b/packages/fs/tool-fs-search/src/shell-quote.ts index 9453b8e255..ea67abf449 100644 --- a/packages/fs/tool-fs-search/src/shell-quote.ts +++ b/packages/fs/tool-fs-search/src/shell-quote.ts @@ -1,12 +1,9 @@ /** - * The one shell-quoting helper both search tools MUST route every - * model-controlled value through before it enters an `rg` command string. The - * bash seam (`ctx.bash`) accepts a command STRING, not an argv vector, so this - * is the safety boundary that stops a `pattern`, `path`, or `include` from - * breaking out of its argument and injecting shell syntax. - * - * Command builders in `glob.ts` / `grep.ts` must never hand-roll quoting or - * concatenate an unquoted model value — they call {@link singleQuote}. + * POSIX single-quoting helper retained for compatibility with older + * deployments and tests. The current `glob`/`grep` command builders spawn the + * packaged ripgrep binary with a plain argv vector — no shell layer exists — + * so no quoting is involved; this module is kept because its export is part + * of the package surface. * * @module @deepseek-ai/dsh-tool-fs-search/shell-quote */ diff --git a/packages/fs/tool-fs-search/tests/integration.spec.ts b/packages/fs/tool-fs-search/tests/integration.spec.ts index 8cb96e7e66..dc7a88b30f 100644 --- a/packages/fs/tool-fs-search/tests/integration.spec.ts +++ b/packages/fs/tool-fs-search/tests/integration.spec.ts @@ -1,15 +1,16 @@ /** - * Integration tests: the REAL local bash executor (`dsh-bash-local`) plus a - * REAL ripgrep binary, exercised through `ctx.tools.execute()`. These verify - * the WORLD — actual files on disk are discovered and grepped, hostile - * patterns stay inert in a real shell, and real `rg` stderr classifies into - * the `SEARCH_*` vocabulary. The whole suite self-skips when `rg` is not on - * PATH (a CI accommodation mirroring the keyless e2e skip); the fake-executor - * suite (tools.spec.ts) carries the coverage gate. + * Integration tests: the REAL local subprocess service plus the PACKAGED + * ripgrep binary (`@vscode/ripgrep`), exercised through `ctx.tools.execute()`. + * These verify the WORLD — actual files on disk are discovered and grepped, + * hostile patterns stay inert (they are plain argv elements; there is no + * shell layer to escape), and real `rg` stderr classifies into the + * `SEARCH_*` vocabulary. The binary ships inside the npm dependency, so the + * suite runs on every platform without a system `rg` install; the + * fake-service suite (tools.spec.ts) carries the coverage gate. */ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -17,14 +18,11 @@ import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' -import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search' const testToolSignal = new AbortController().signal -const hasRg = spawnSync('rg', ['--version'], { encoding: 'utf8' }).status === 0 - let dir: string let ctx: Context @@ -43,7 +41,10 @@ function text(result: { content: { type: string; text?: string }[] }): string { return result.content.filter(b => b.type === 'text').map(b => b.text).join('') } -describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () => { +/** The fixture workspace as a session cwd, so relative paths resolve inside `dir`. */ +const agent = () => ({ session: { header: { id: 'session-int', cwd: dir } } }) + +describe('search tools over the real subprocess service + the packaged rg', () => { beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'dsh-search-int-')) await mkdir(join(dir, 'src'), { recursive: true }) @@ -54,7 +55,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () await writeFile(join(dir, 'notes.md'), 'alpha appears here too\n') await writeFile(join(dir, '.hidden.ts'), 'export const hidden = 3\n') await writeFile(join(dir, '.git', 'config.ts'), 'never listed\n') - await writeFile(join(dir, 'spaced dir', "wei'rd \"name\".ts"), 'const inside = true\n') + await writeFile(join(dir, 'spaced dir', "wei'rd name.ts"), 'const inside = true\n') // Deterministic --sort=modified order: alpha oldest, beta newest. await utimes(join(dir, 'src', 'alpha.ts'), new Date(2000, 0, 1), new Date(2000, 0, 1)) await utimes(join(dir, 'src', 'beta.ts'), new Date(2020, 0, 1), new Date(2020, 0, 1)) @@ -63,7 +64,6 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(LocalSubprocessService) - await ctx.plugin(LocalBashExecutor, { cwd: dir, timeoutMs: 20_000 }) await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: true }) }) @@ -73,33 +73,33 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () describe('glob', () => { it('discovers files by pattern, sorted by modification time, hidden included, .git excluded', async () => { - const result = await call('glob', { pattern: '**/*.ts' }) + const result = await call('glob', { pattern: '**/*.ts' }, agent()) expect(result.isError).toBe(false) const paths = text(result).split('\n') - expect(paths.indexOf('src/alpha.ts')).toBeLessThan(paths.indexOf('src/beta.ts')) + expect(paths.indexOf(join('src', 'alpha.ts'))).toBeLessThan(paths.indexOf(join('src', 'beta.ts'))) expect(paths).toContain('.hidden.ts') - expect(paths).toContain("spaced dir/wei'rd \"name\".ts") - expect(paths).not.toContain('.git/config.ts') + expect(paths).toContain(join('spaced dir', "wei'rd name.ts")) + expect(paths).not.toContain(join('.git', 'config.ts')) expect(paths).not.toContain('notes.md') }) it('scopes to a directory search root (path arg)', async () => { - const result = await call('glob', { pattern: '*.ts', path: 'src' }) - expect(text(result).split('\n').sort()).toEqual(['src/alpha.ts', 'src/beta.ts']) + const result = await call('glob', { pattern: '*.ts', path: 'src' }, agent()) + expect(text(result).split('\n').sort()).toEqual([join('src', 'alpha.ts'), join('src', 'beta.ts')]) }) it('reports zero discoveries as No files found', async () => { - expect(text(await call('glob', { pattern: '*.nomatch' }))).toBe('No files found') + expect(text(await call('glob', { pattern: '*.nomatch' }, agent()))).toBe('No files found') }) it('excludes VCS internals even when the search root IS the VCS directory', async () => { // The prune glob alone never matches root-prefixed paths when rg is // rooted at .git; the paired contents glob keeps the exclusion airtight. - expect(text(await call('glob', { pattern: '*', path: '.git' }))).toBe('No files found') + expect(text(await call('glob', { pattern: '*', path: '.git' }, agent()))).toBe('No files found') }) it('classifies an invalid glob as SEARCH_INVALID_PATTERN', async () => { - const result = await call('glob', { pattern: '[' }) + const result = await call('glob', { pattern: '[' }, agent()) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_INVALID_PATTERN' } }) }) @@ -107,37 +107,42 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () describe('grep', () => { it('greps a directory tree with grouped, line-numbered output', async () => { - const result = await call('grep', { pattern: 'alpha' }) + const result = await call('grep', { pattern: 'alpha' }, agent()) expect(result.isError).toBe(false) const output = text(result) expect(output).toContain('Found 3 matches') - expect(output).toContain('src/alpha.ts\nLine 1: export const alpha = 1\nLine 2: // TODO: refit alpha') + expect(output).toContain(`${join('src', 'alpha.ts')}\nLine 1: export const alpha = 1\nLine 2: // TODO: refit alpha`) expect(output).toContain('notes.md\nLine 1: alpha appears here too') }) it('greps a single FILE target', async () => { - const result = await call('grep', { pattern: 'alpha', path: 'notes.md' }) + const result = await call('grep', { pattern: 'alpha', path: 'notes.md' }, agent()) expect(text(result)).toBe('Found 1 match\n\nnotes.md\nLine 1: alpha appears here too') }) it('greps a directory target with an include filter', async () => { - const result = await call('grep', { pattern: 'alpha', path: '.', include: '*.ts' }) + const result = await call('grep', { pattern: 'alpha', path: '.', include: '*.ts' }, agent()) const output = text(result) expect(output).toContain('alpha.ts') expect(output).not.toContain('notes.md') }) - it('a hostile pattern stays inert (no command substitution, the world untouched)', async () => { + it('a hostile pattern stays inert (a plain argv element, the world untouched)', async () => { + // There is no shell layer between the argv vector and rg, so the pattern + // is a literal regex — but the world-untouched guarantee is the shipped + // contract, and a future shell-wrapping change must not reintroduce it. + // The canary name carries no path so the regex stays valid on every + // platform (a Windows path's backslashes would be regex escapes). const canary = join(dir, 'pwned') - const result = await call('grep', { pattern: `$(touch ${canary})` }) + const result = await call('grep', { pattern: '$(touch pwned)' }, agent()) expect(result.isError).toBe(false) // exit 1: found nothing, executed nothing expect(text(result)).toBe('No matches found') - expect(spawnSync('test', ['-e', canary]).status).not.toBe(0) + expect(existsSync(canary)).toBe(false) }) it('a leading-dash pattern is a pattern, not a flag', async () => { await writeFile(join(dir, 'dashes.txt'), 'value --flag value\n') - const result = await call('grep', { pattern: '--flag', path: 'dashes.txt' }) + const result = await call('grep', { pattern: '--flag', path: 'dashes.txt' }, agent()) expect(text(result)).toBe('Found 1 match\n\ndashes.txt\nLine 1: value --flag value') }) @@ -155,7 +160,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () }) describe('per-session cwd', () => { - it('resolves the search in the SESSION workspace, not the executor config cwd', async () => { + it('resolves the search in the SESSION workspace, not the process cwd', async () => { const sessionDir = await mkdtemp(join(tmpdir(), 'dsh-search-session-')) try { await writeFile(join(sessionDir, 'only-here.ts'), 'const sessionFile = true\n') @@ -170,7 +175,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () }) }) - describe('pre-dispatch cancellation and bash-start failures', () => { + describe('pre-dispatch cancellation and spawn failures', () => { it('a pre-aborted registry call is ABORTED_BEFORE_DISPATCH', async () => { const controller = new AbortController() controller.abort() diff --git a/packages/fs/tool-fs-search/tests/load-path.spec.ts b/packages/fs/tool-fs-search/tests/load-path.spec.ts index 71022720fc..1d1e348c09 100644 --- a/packages/fs/tool-fs-search/tests/load-path.spec.ts +++ b/packages/fs/tool-fs-search/tests/load-path.spec.ts @@ -3,14 +3,15 @@ * a NAMESPACE plugin with `inject` — so a stray `export default apply` would * make the cordis Loader's `unwrapExports` (`exports.default ?? exports`) * collapse the module to the bare `apply` function, DROPPING `inject`. The - * plugin would then read `ctx.bash` without having injected it and throw + * plugin would then read `ctx.subprocess` without having injected it and throw * `cannot get property … without inject` the moment it loads (postmortem 0001). * * A hand-built `ctx.plugin({ apply, inject })` mount CANNOT catch that — it * bypasses `unwrapExports`. So this test unwraps the module through the REAL - * `Loader.prototype.unwrapExports` and mounts the result over a bash executor, - * exercising the exact path the Loader uses. Prove the guard bites: add - * `export default apply` to `src/index.ts`, watch this go red, revert. + * `Loader.prototype.unwrapExports` and mounts the result over the real local + * subprocess service, exercising the exact path the Loader uses. Prove the + * guard bites: add `export default apply` to `src/index.ts`, watch this go + * red, revert. */ import { describe, expect, it } from 'vitest' @@ -18,48 +19,9 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import { BashExecutor } from '@deepseek-ai/dsh-bash' -import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as toolFsSearch from '@deepseek-ai/dsh-tool-fs-search' -const RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1' - -/** - * Deterministic bash service for this Loader guard: the test wants to exercise - * the real unwrap/inject path, not depend on whether the host image has rg. - */ -class ProbeSuccessBashExecutor extends BashExecutor { - override resolve(request: BashExecRequest): BashExecSpec { - return { - command: request.command, - workdir: request.workdir ?? '/work', - timeoutMs: request.timeoutMs ?? 60_000, - stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, - signal: request.signal, - sandboxPolicy: request.sandboxPolicy, - } - } - - override run(spec: BashExecSpec): Promise { - if (spec.command !== RG_PROBE_COMMAND) { - throw new Error(`unexpected command in load-path guard: ${spec.command}`) - } - return Promise.resolve({ - exitCode: 0, - signal: null, - timedOut: false, - aborted: false, - timeoutMs: spec.timeoutMs, - stdout: { text: '', truncated: false }, - stderr: { text: '', truncated: false }, - }) - } - - override start(): BashProcess { - throw new Error('load-path guard must not start background processes') - } -} - describe('dsh-tool-fs-search real-load-path guard', () => { it('has no default export and keeps name/inject/Config through unwrapExports', () => { expect('default' in toolFsSearch).toBe(false) @@ -68,16 +30,16 @@ describe('dsh-tool-fs-search real-load-path guard', () => { const unwrapped = loader.unwrapExports(toolFsSearch) as Record expect(unwrapped).toBe(toolFsSearch) expect(unwrapped.name).toBe('tool-fs-search') - expect(unwrapped.inject).toEqual(['tools', 'systemPrompt', 'bash']) + expect(unwrapped.inject).toEqual(['tools', 'systemPrompt', 'subprocess']) expect(typeof unwrapped.Config).toBe('function') expect(typeof unwrapped.apply).toBe('function') }) - it('boots over ctx.bash through the unwrapped module without an inject error', async () => { + it('boots over ctx.subprocess through the unwrapped module without an inject error', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(ProbeSuccessBashExecutor) + await ctx.plugin(LocalSubprocessService) const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(toolFsSearch) as Parameters[0] diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index 389d8dbe05..d786fa3181 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -1,23 +1,24 @@ /** - * Consumer-surface tests for the search tools over a FAKE bash executor and a - * FAKE spill backend, exercised through `ctx.tools.execute()` so nothing - * bypasses the tool registry. The fake executor makes every seam outcome - * scriptable — registration-time `rg` probing, truncated stdout with/without a - * raw spill path, abort/timeout, signal kills, ripgrep exit codes — so these - * tests verify schemas, argument validation, shell-safe command construction, - * workdir derivation, signal forwarding, `SEARCH_*` error classification, - * retention, formatted-result spill handoff, and the no-background-task - * invariant. Real-`rg` behavior is pinned separately in integration.spec.ts. + * Consumer-surface tests for the search tools over a FAKE subprocess service + * and a FAKE spill backend, exercised through `ctx.tools.execute()` so nothing + * bypasses the tool registry. The fake service makes every seam outcome + * scriptable — spawn failure, truncated stdout with/without a raw spill path, + * abort/timeout kills, signal kills, ripgrep exit codes — so these tests + * verify schemas, argument validation, argv construction, workdir derivation, + * signal forwarding, `SEARCH_*` error classification, retention, + * formatted-result spill handoff, and the no-background-task invariant. + * Real-`rg` behavior is pinned separately in integration.spec.ts. */ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { join, sep } from 'node:path' -import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' +import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH, type ToolExecutionToken } from '@deepseek-ai/dsh-tools' -import { BashExecutor } from '@deepseek-ai/dsh-bash' -import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' +import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH, type ToolExecution, type ToolExecutionToken } from '@deepseek-ai/dsh-tools' +import { SubprocessService } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessCollectedOutputs, SubprocessHandle, SubprocessOutcome, SubprocessOutputRead, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { rgPath } from '@vscode/ripgrep' import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill' import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill' import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search' @@ -31,68 +32,124 @@ import { presentGrepCall, presentGrepResult, previewLine, + runRipgrep, sampleAcrossTopLevel, toWorkdirRelative, } from '@deepseek-ai/dsh-tool-fs-search' const testToolSignal = new AbortController().signal -const RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1' -/** A successful run result over the given stdout; overrides script the failure shapes. */ -function runResult(stdout: string, overrides?: Partial): BashRunResult { +/** One scripted collect-mode stream, returned by `readFrom(0)` after settlement. */ +interface ScriptedStream { + text: string + lossy?: boolean + spillPath?: string +} + +/** One scripted spawn: exit facts plus the collected streams the tool reads. */ +interface ScriptedRun { + outcome: SubprocessOutcome + stdout: ScriptedStream + stderr: ScriptedStream +} + +/** A successful run over the given stdout; overrides script the failure shapes. */ +function runResult( + stdout: string, + overrides?: Partial & { stdout?: Partial; stderr?: ScriptedStream }, +): ScriptedRun { + const { stdout: stdoutOverrides, stderr: stderrOverrides, ...outcome } = overrides ?? {} return { - exitCode: 0, - signal: null, - timedOut: false, - aborted: false, - timeoutMs: 60_000, - stdout: { text: stdout, truncated: false }, - stderr: { text: '', truncated: false }, - ...overrides, + outcome: { exitCode: 0, signal: null, ...outcome }, + stdout: { text: stdout, ...stdoutOverrides }, + stderr: { text: '', ...stderrOverrides }, + } +} + +/** A fixed-response collect-mode reader: the tools read each stream once, from 0, after settlement. */ +class FakeReader implements SubprocessOutputReader { + constructor(private readonly read: ScriptedStream) {} + + readFrom(_fromByte: number): SubprocessOutputRead { + return { + text: this.read.text, + nextOffset: 0, + lossy: this.read.lossy ?? false, + ...this.read.spillPath !== undefined ? { spillPath: this.read.spillPath } : {}, + } } } /** - * A scriptable fake executor: `resolve()` mirrors the real request→spec - * defaulting (workdir falls back to `/work`), `run()` returns whatever the - * test armed via `handler`, and `start()` throws — the search tools must NEVER - * create a background task. + * A scriptable subprocess handle: `done` resolves with the scripted outcome + * (or rejects with the scripted error), `terminate()` records the call, and + * the spec's abort signal marks the handle terminated — mirroring the seam's + * abort→terminate escalation. */ -class FakeBash extends BashExecutor { - probeRequests: BashExecRequest[] = [] - probeSpecs: BashExecSpec[] = [] - requests: BashExecRequest[] = [] - specs: BashExecSpec[] = [] - startCalls = 0 - forwardSignal = true - probeResult: BashRunResult = runResult('') - probeError?: Error - handler: (spec: BashExecSpec) => BashRunResult = () => runResult('') +class FakeHandle implements SubprocessHandle { + readonly pid = 4242 + readonly stdin = undefined + readonly stdout = undefined + readonly stderr = undefined + readonly collected: SubprocessCollectedOutputs + readonly done: Promise + /** True once `done` settled — the search tools must never leave a spawn running. */ + settled = false + /** True when the handle's termination path ran (abort signal or explicit terminate). */ + terminated = false + /** Scripted handle that drops one requested collect reader (the defensive branch). */ + readonly dropReaders: boolean - override resolve(request: BashExecRequest): BashExecSpec { - if (request.command === RG_PROBE_COMMAND) this.probeRequests.push(request) - else this.requests.push(request) - return { - command: request.command, - workdir: request.workdir ?? '/work', - timeoutMs: request.timeoutMs ?? 60_000, - stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, - ...this.forwardSignal ? { signal: request.signal } : {}, - sandboxPolicy: request.sandboxPolicy, + constructor(spec: SubprocessSpawnSpec, script: () => ScriptedRun | { reject: Error }, dropReaders = false) { + this.dropReaders = dropReaders + // The abort listener attaches BEFORE the scripted run resolves, mirroring + // a real spawn: the escalation is armed when the process starts. + spec.signal?.addEventListener('abort', () => { this.terminated = true }, { once: true }) + const scripted = script() + if ('reject' in scripted) { + // A spawn failure produces no process output, so no readers exist. + this.collected = {} + this.done = Promise.reject(scripted.reject) + } else { + this.collected = { + ...dropReaders ? {} : { stdout: new FakeReader(scripted.stdout), stderr: new FakeReader(scripted.stderr) }, + } + this.done = Promise.resolve(scripted.outcome) } + this.done.then( + () => { this.settled = true }, + () => { this.settled = true }, + ) } - override async run(spec: BashExecSpec): Promise { - if (spec.command === RG_PROBE_COMMAND) { - this.probeSpecs.push(spec) - if (this.probeError) throw this.probeError - return this.probeResult - } - this.specs.push(spec) - return this.handler(spec) + + terminate(): void { + this.terminated = true } - override start(): BashProcess { - this.startCalls++ - throw new Error('search tools must never start a background task') + + waitForExit(_signal?: AbortSignal): Promise { + return Promise.resolve(true) + } +} + +/** + * A scriptable fake subprocess service: `spawn()` records every spec and + * returns a handle scripted by the armed `handler`. The search tools must + * never spawn outside a single awaited foreground call, so every test can + * assert on the exact spawn specs and settled handles. + */ +class FakeSubprocess extends SubprocessService { + spawns: SubprocessSpawnSpec[] = [] + handles: FakeHandle[] = [] + /** Arms the per-spawn script; a `{ reject }` return scripts a spawn-level failure. */ + handler: (spec: SubprocessSpawnSpec) => ScriptedRun | { reject: Error } = () => runResult('') + /** When true, spawned handles drop their collect readers (the defensive branch). */ + dropReaders = false + + override spawn(spec: SubprocessSpawnSpec): SubprocessHandle { + this.spawns.push(spec) + const handle = new FakeHandle(spec, () => this.handler(spec), this.dropReaders) + this.handles.push(handle) + return handle } } @@ -115,8 +172,6 @@ class FakeSpill extends SpillStore { interface SetupOptions { config?: Partial spill?: boolean - probeError?: Error - probeResult?: BashRunResult } const DEFAULT_CONFIG = { sampleOverCapGlobResults: true } satisfies ToolFsSearch.Config @@ -127,26 +182,12 @@ async function setup(options: SetupOptions = {}) { ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(FakeBash) - const bash = ctx.bash as FakeBash - if (options.probeResult) bash.probeResult = options.probeResult - if (options.probeError) bash.probeError = options.probeError + await ctx.plugin(FakeSubprocess) + const subprocess = ctx.subprocess as FakeSubprocess if (options.spill === true) await ctx.plugin(FakeSpill) const fiber = await ctx.plugin(ToolFsSearch, { ...DEFAULT_CONFIG, ...options.config }) const spill = options.spill === true ? ctx.get('spillStore') as FakeSpill : undefined - return { ctx, bash, spill, fiber, warnings } -} - -/** Assert plugin setup rejects without letting Vitest pretty-print a live Context on failure. */ -async function expectSetupRejects(options: SetupOptions, message: RegExp): Promise { - let thrown: string | undefined - try { - const loaded = await setup(options) - await loaded.fiber.dispose() - } catch (error: unknown) { - thrown = error instanceof Error ? error.message : String(error) - } - expect(thrown).toMatch(message) + return { ctx, subprocess, spill, fiber, warnings } } /** A stand-in agent whose session header carries the given cwd (and a stable id). */ @@ -180,11 +221,11 @@ function matchLine(path: string, lineNumber: number, lineText: string): string { } describe('registration', () => { - it('registers glob and grep with their prompt sections', async () => { - const { ctx, bash } = await setup() - expect(bash.probeRequests).toHaveLength(1) - expect(bash.probeRequests[0]?.command).toBe(RG_PROBE_COMMAND) - expect(bash.probeRequests[0]).not.toHaveProperty('workdir') + it('registers glob and grep unconditionally with their prompt sections', async () => { + const { ctx, subprocess } = await setup() + // Registration performs NO load-time probe: the packaged binary is always + // available, so nothing spawns until a tool call. + expect(subprocess.spawns).toHaveLength(0) expect(ctx.tools.schemas().map(s => s.name).sort()).toEqual(['glob', 'grep']) const prompt = renderPrompt(await ctx.systemPrompt.assemble()) expect(prompt).toContain('Use the glob tool') @@ -195,32 +236,11 @@ describe('registration', () => { expect(glob?.description).toContain('sampled across top-level entries') }) - it('does not register glob or grep when the bash executor cannot find rg', async () => { - const { ctx, warnings } = await setup({ probeResult: runResult('', { exitCode: 1 }) }) - expect(ctx.tools.schemas()).toHaveLength(0) - const sections = (await ctx.systemPrompt.assemble()).sections.map(s => s.name) - expect(sections).not.toContain('tool:glob') - expect(sections).not.toContain('tool:grep') - expect(warnings).toEqual([ - 'tool-fs-search: ripgrep (rg) not found on the bash executor PATH; glob/grep tools not registered', - ]) - }) - - it('rejects plugin load when the rg availability probe cannot run', async () => { - await expectSetupRejects({ probeError: new Error('spawn bash ENOENT') }, /spawn bash ENOENT/) - }) - - it('rejects plugin load when the rg availability probe is aborted or killed', async () => { - await expectSetupRejects({ - probeResult: runResult('', { aborted: true, exitCode: null, signal: 'SIGTERM' }), - }, /tool-fs-search: ripgrep availability probe did not complete/) - }) - - it('stays pending until ctx.bash exists (inject)', async () => { + it('stays pending until ctx.subprocess exists (inject)', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(ToolFsSearch, DEFAULT_CONFIG) // no bash executor + await ctx.plugin(ToolFsSearch, DEFAULT_CONFIG) // no subprocess service expect(ctx.tools.schemas()).toHaveLength(0) }) @@ -276,139 +296,176 @@ describe('config validation', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(FakeBash) + await ctx.plugin(FakeSubprocess) await expect(ctx.plugin(ToolFsSearch, { ...DEFAULT_CONFIG, ...config })).rejects.toThrow(new RegExp(`tool-fs-search: ${name} must be a positive integer`)) }) }) -describe('command construction (shell-safe)', () => { - it('glob: fixed rg --files template with quoted pattern and paired VCS excludes', () => { - const command = buildGlobCommand({ pattern: '**/*.ts' }) - expect(command).toBe( - "rg --files --glob='**/*.ts' --sort=modified --no-ignore --hidden " - + "--glob='!**/.git' --glob='!**/.git/**' --glob='!**/.svn' --glob='!**/.svn/**' " - + "--glob='!**/.hg' --glob='!**/.hg/**' --glob='!**/.bzr' --glob='!**/.bzr/**' " - + "--glob='!**/.jj' --glob='!**/.jj/**' --glob='!**/.sl' --glob='!**/.sl/**'", - ) +describe('command construction (plain argv)', () => { + it('glob: fixed rg --files argv with the pattern and paired VCS excludes', () => { + expect(buildGlobCommand({ pattern: '**/*.ts' })).toEqual([ + '--files', + '--glob=**/*.ts', + '--sort=modified', + '--no-ignore', + '--hidden', + '--glob=!**/.git', '--glob=!**/.git/**', + '--glob=!**/.svn', '--glob=!**/.svn/**', + '--glob=!**/.hg', '--glob=!**/.hg/**', + '--glob=!**/.bzr', '--glob=!**/.bzr/**', + '--glob=!**/.jj', '--glob=!**/.jj/**', + '--glob=!**/.sl', '--glob=!**/.sl/**', + ]) }) - it('glob: the search root rides behind -- and is quoted', () => { - const command = buildGlobCommand({ pattern: '*.md', path: 'docs dir' }) - expect(command).toContain("-- 'docs dir'") + it('glob: the search root rides behind -- as a plain element', () => { + expect(buildGlobCommand({ pattern: '*.md', path: 'docs dir' })).toEqual(['--files', '--glob=*.md', '--sort=modified', '--no-ignore', '--hidden', + '--glob=!**/.git', '--glob=!**/.git/**', + '--glob=!**/.svn', '--glob=!**/.svn/**', + '--glob=!**/.hg', '--glob=!**/.hg/**', + '--glob=!**/.bzr', '--glob=!**/.bzr/**', + '--glob=!**/.jj', '--glob=!**/.jj/**', + '--glob=!**/.sl', '--glob=!**/.sl/**', + '--', 'docs dir']) }) - it('grep: fixed rg --json template with the pattern in --regexp= form', () => { - expect(buildGrepCommand({ pattern: 'foo.*bar' })).toBe("rg --json --regexp='foo.*bar'") + it('grep: fixed rg --json argv with the pattern in --regexp= form', () => { + expect(buildGrepCommand({ pattern: 'foo.*bar' })).toEqual(['--json', '--regexp=foo.*bar']) }) - it('grep: include and path are quoted, include in --glob= form, path behind --', () => { - const command = buildGrepCommand({ pattern: 'x', path: '-leading-dash', include: '*.{ts,tsx}' }) - expect(command).toBe("rg --json --regexp='x' --glob='*.{ts,tsx}' -- '-leading-dash'") + it('grep: include in --glob= form, path behind --, both plain elements', () => { + expect(buildGrepCommand({ pattern: 'x', path: '-leading-dash', include: '*.{ts,tsx}' })) + .toEqual(['--json', '--regexp=x', '--glob=*.{ts,tsx}', '--', '-leading-dash']) }) it.each([ - ['a command-substitution pattern', '$(rm -rf /)', "'$(rm -rf /)'"], - ['a backtick pattern', '`touch pwned`', "'`touch pwned`'"], - ['a pattern with double quotes and spaces', 'say "hi there"', '\'say "hi there"\''], - ['a pattern with single quotes', "it's", '\'it\'\\\'\'s\''], - ['a pattern with newlines', 'a\nb', "'a\nb'"], - ['a leading-dash pattern', '--flag', "'--flag'"], - ['glob metacharacters', '*?[a-z]{x,y}', "'*?[a-z]{x,y}'"], - ])('quotes %s into one inert shell word', (_label, raw, quoted) => { - expect(buildGrepCommand({ pattern: raw })).toBe(`rg --json --regexp=${quoted}`) + ['a command-substitution pattern', '$(rm -rf /)'], + ['a backtick pattern', '`touch pwned`'], + ['a pattern with double quotes and spaces', 'say "hi there"'], + ['a pattern with single quotes', "it's"], + ['a pattern with newlines', 'a\nb'], + ['a leading-dash pattern', '--flag'], + ['glob metacharacters', '*?[a-z]{x,y}'], + ])('keeps %s as ONE inert argv element (no shell layer to escape)', (_label, raw) => { + // The argv vector is handed to rg verbatim: hostile text cannot break out + // of its argument because there is no shell between the vector and rg. + expect(buildGrepCommand({ pattern: raw })).toEqual(['--json', `--regexp=${raw}`]) + expect(buildGlobCommand({ pattern: raw })[1]).toBe(`--glob=${raw}`) }) }) describe('workdir derivation and signal forwarding', () => { - it('forwards the session cwd as the request workdir', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('a.ts\n') + it('forwards the session cwd as the spawn cwd', async () => { + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('a.ts\n') await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/sessions/s1') }) - expect(bash.requests[0]?.workdir).toBe('/sessions/s1') - expect(bash.specs[0]?.workdir).toBe('/sessions/s1') + expect(subprocess.spawns[0]?.cwd).toBe('/sessions/s1') }) - it('omits the request workdir without a session cwd so resolve() defaults apply', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('a.ts\n') + it('defaults the spawn cwd to process.cwd() without a session cwd', async () => { + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('a.ts\n') await call(ctx, 'glob', { pattern: '*' }, { agent: agent() }) - expect(bash.requests[0]).not.toHaveProperty('workdir') - expect(bash.specs[0]?.workdir).toBe('/work') - // A non-agent caller takes the same default path. + expect(subprocess.spawns[0]?.cwd).toBe(process.cwd()) + // A non-agent caller takes the same default. await call(ctx, 'grep', { pattern: 'x' }) - expect(bash.requests[1]).not.toHaveProperty('workdir') + expect(subprocess.spawns[1]?.cwd).toBe(process.cwd()) }) - it('forwards exec.signal into the bash spec', async () => { - const { ctx, bash } = await setup() + it('spawns the packaged ripgrep binary with the fixed argv and budgeted collect streams', async () => { + const { ctx, subprocess } = await setup({ config: { rawOutputMaxBytes: 1234 } }) + subprocess.handler = () => runResult('', { exitCode: 1 }) + await call(ctx, 'grep', { pattern: 'needle' }) + const spec = subprocess.spawns[0] + expect(spec?.argv[0]).toBe(rgPath) + expect(spec?.argv).toEqual([rgPath, '--json', '--regexp=needle']) + expect(spec?.stdio.stdin).toBe('ignore') + // stdout gets the tool's parse budget; stderr is a diagnostic excerpt. + expect((spec?.stdio.stdout as { maxBytes: number }).maxBytes).toBe(1234) + expect(spec?.graceMs).toBe(3_000) + }) + + it('forwards exec.signal into the spawn spec', async () => { + const { ctx, subprocess } = await setup() const controller = new AbortController() - bash.handler = () => runResult('') + subprocess.handler = () => runResult('') const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal }) - expect(bash.specs[0]?.signal).toBe(controller.signal) + expect(subprocess.spawns[0]?.signal).toBe(controller.signal) expect(result.isError).toBe(false) }) - it('reports the bash executor timeout as SEARCH_ABORTED with the budget', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('', { timedOut: true, timeoutMs: 1234, exitCode: null, signal: 'SIGTERM' }) - const result = await call(ctx, 'glob', { pattern: '*' }) + it('reports an abort fired during the run as SEARCH_ABORTED', async () => { + // The cooperative tool timeout or caller cancellation aborts exec.signal; + // the subprocess seam then kills the process tree. The tool classifies + // the first cause it owns: the abort. + const { ctx, subprocess } = await setup() + const controller = new AbortController() + subprocess.handler = () => { + controller.abort('timeout') + return runResult('', { exitCode: null, signal: 'SIGTERM' }) + } + const result = await call(ctx, 'glob', { pattern: '*' }, { signal: controller.signal }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { code: 'SEARCH_ABORTED' } }) - expect(text(result)).toContain('timed out after 1234ms') + expect(text(result)).toContain('aborted before completion') + expect(subprocess.handles[0]?.terminated).toBe(true) }) - it('skips a pre-aborted registry call before run()', async () => { - const { ctx, bash } = await setup() + it('skips a pre-aborted registry call before spawn()', async () => { + const { ctx, subprocess } = await setup() const controller = new AbortController() controller.abort() - bash.handler = () => { throw new Error('aborted before spawn') } + subprocess.handler = () => { throw new Error('aborted before spawn') } const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }) - expect(bash.specs).toHaveLength(0) + expect(subprocess.spawns).toHaveLength(0) }) - it('translates a run() rejection after the forwarded signal aborts', async () => { - const { ctx, bash } = await setup() + it('fails a pre-aborted exec.signal before spawn with SEARCH_ABORTED', async () => { + // Direct unit check of runRipgrep's own pre-spawn guard: the registry + // intercepts most pre-aborted calls, but a signal that aborts between the + // registry check and execute reaches this branch. + const { ctx } = await setup() const controller = new AbortController() - bash.handler = () => { + controller.abort() + const exec = { signal: controller.signal, name: 'glob', callId: CallId('direct-pre-abort') } as unknown as ToolExecution + await expect(runRipgrep(ctx, exec, 'glob', ['--files'], 1_000_000)).rejects + .toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' }) + }) + + it('translates a spawn rejection into SEARCH_FAILED even when the signal aborts concurrently', async () => { + // The seam rejects only for infrastructure failures (unusable workdir, + // missing binary); the abort happened after dispatch, so the launch + // failure is the reportable cause with the original error chained. + const { ctx, subprocess } = await setup() + const controller = new AbortController() + subprocess.handler = () => { controller.abort('cancel search') - throw new Error('executor stopped on abort') + return { reject: new Error('spawn ENOENT') } } const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_ABORTED' } }) - expect(text(result)).toContain('aborted before completion') + expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } }) + expect(text(result)).toContain('could not start') }) - it('translates an aborted executor result after dispatch starts', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('', { aborted: true, exitCode: null }) - - const result = await call(ctx, 'glob', { pattern: '*' }) - - expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_ABORTED' } }) - expect(text(result)).toContain('aborted before completion') - }) - - it('translates a run() rejection without an abort (unusable workdir) into SEARCH_FAILED', async () => { - const { ctx, bash } = await setup() - bash.forwardSignal = false - bash.handler = () => { throw new Error('spawn bash ENOENT') } + it('rejects when the subprocess implementation drops a requested collect stream', async () => { + const { ctx, subprocess } = await setup() + subprocess.dropReaders = true const result = await call(ctx, 'glob', { pattern: '*' }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } }) - expect(text(result)).toContain('could not start') + expect(text(result)).toContain('no collected output streams') }) }) describe('exit semantics and failure classification', () => { it('exit 1 is a successful empty search', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('', { exitCode: 1 }) + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('', { exitCode: 1 }) const glob = await call(ctx, 'glob', { pattern: '*.nope' }) expect(glob.isError).toBe(false) expect(text(glob)).toBe('No files found') @@ -418,108 +475,100 @@ describe('exit semantics and failure classification', () => { }) it('a regex parse error classifies as SEARCH_INVALID_PATTERN', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: regex parse error:\n (\nerror: unclosed group', truncated: false } }) + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: regex parse error:\n (\nerror: unclosed group' } }) const result = await call(ctx, 'grep', { pattern: '(' }) expect(result.error).toMatchObject({ info: { code: 'SEARCH_INVALID_PATTERN' } }) expect(text(result)).toContain('regex parse error') }) it('a glob parse error classifies as SEARCH_INVALID_PATTERN', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: error parsing glob \'[\': unclosed character class', truncated: false } }) + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: error parsing glob \'[\': unclosed character class' } }) const result = await call(ctx, 'glob', { pattern: '[' }) expect(result.error).toMatchObject({ info: { code: 'SEARCH_INVALID_PATTERN' } }) }) - it('a missing rg binary classifies as SEARCH_FAILED naming ripgrep', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('', { exitCode: 127, stderr: { text: 'bash: line 1: rg: command not found', truncated: false } }) + it('a failed ripgrep launch classifies as SEARCH_FAILED naming ripgrep', async () => { + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('', { exitCode: 127, stderr: { text: 'sh: rg: command not found' } }) const result = await call(ctx, 'glob', { pattern: '*' }) expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } }) expect(text(result)).toContain('requires ripgrep (rg)') // The same classification holds from either evidence alone: the 127 exit // with silent stderr, or a shell's command-not-found text on another exit. - bash.handler = () => runResult('', { exitCode: 127 }) + subprocess.handler = () => runResult('', { exitCode: 127 }) expect(text(await call(ctx, 'glob', { pattern: '*' }))).toContain('requires ripgrep (rg)') - bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'sh: rg: command not found', truncated: false } }) + subprocess.handler = () => runResult('', { exitCode: 2, stderr: { text: 'sh: rg: command not found' } }) expect(text(await call(ctx, 'grep', { pattern: 'x' }))).toContain('requires ripgrep (rg)') }) it('other nonzero exits are SEARCH_FAILED carrying the stderr excerpt', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: missing.dir: IO error: no such file or directory', truncated: false } }) + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: missing.dir: IO error: no such file or directory' } }) const result = await call(ctx, 'grep', { pattern: 'x', path: 'missing.dir' }) expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } }) expect(text(result)).toContain('IO error') }) it('a nonzero exit with EMPTY stderr still reports the exit code', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('', { exitCode: 3 }) + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('', { exitCode: 3 }) const result = await call(ctx, 'glob', { pattern: '*' }) expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } }) expect(text(result)).toContain('exit 3') }) it('truncated stderr gains a truncation note and stderr.spillPath is never read', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('', { + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('', { exitCode: 2, - stderr: { text: 'tail of diagnostics', truncated: true, spillPath: '/does/not/exist-and-never-read' }, + stderr: { text: 'tail of diagnostics', lossy: true, spillPath: '/does/not/exist-and-never-read' }, }) const result = await call(ctx, 'grep', { pattern: 'x' }) expect(text(result)).toContain('tail of diagnostics [stderr truncated]') }) it('a signal kill (not timeout, not abort) is SEARCH_FAILED', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('', { exitCode: null, signal: 'SIGKILL' }) + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('', { exitCode: null, signal: 'SIGKILL' }) const result = await call(ctx, 'grep', { pattern: 'x' }) expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } }) expect(text(result)).toContain('SIGKILL') }) it('a null exit with no signal (defensive) is SEARCH_FAILED', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('', { exitCode: null, signal: null }) + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('', { exitCode: null, signal: null }) const result = await call(ctx, 'glob', { pattern: '*' }) expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } }) + expect(text(result)).toContain('killed by signal (unknown)') }) }) describe('raw output acquisition', () => { - it('passes rawOutputMaxBytes to bash as the stdout capture budget', async () => { - const { ctx, bash } = await setup({ config: { rawOutputMaxBytes: 1234 } }) - bash.handler = () => runResult('', { exitCode: 1 }) - await call(ctx, 'glob', { pattern: '*.ts' }) - await call(ctx, 'grep', { pattern: 'needle' }) - expect(bash.requests.map(request => request.stdoutMaxBytes)).toEqual([1234, 1234]) - expect(bash.specs.map(spec => spec.stdoutMaxBytes)).toEqual([1234, 1234]) - }) - it('fails with SEARCH_RAW_OUTPUT_OVERFLOW when truncated stdout has a raw spill path', async () => { - const { ctx, bash } = await setup({ config: { rawOutputMaxBytes: 16 } }) - bash.handler = () => runResult('', { stdout: { text: 'x', truncated: true, spillPath: '/does/not/get-read' } }) + const { ctx, subprocess } = await setup({ config: { rawOutputMaxBytes: 16 } }) + subprocess.handler = () => runResult('', { stdout: { text: 'x', lossy: true, spillPath: '/does/not/get-read' } }) const result = await call(ctx, 'glob', { pattern: '*' }) expect(result.error).toMatchObject({ info: { code: 'SEARCH_RAW_OUTPUT_OVERFLOW' } }) expect(text(result)).toContain('narrow pattern, path, or include') }) it('fails with SEARCH_RAW_OUTPUT_OVERFLOW when UNTRUNCATED inline stdout exceeds the cap', async () => { - // An executor retaining more inline than this package's cap (or a - // deployment lowering rawOutputMaxBytes below the bash retention) must not - // smuggle an over-cap parse through the untruncated path. - const { ctx, bash } = await setup({ config: { rawOutputMaxBytes: 16 } }) - bash.handler = () => runResult(`${'x'.repeat(64)}\n`) + // A subprocess implementation retaining more inline than this package's + // cap (or a deployment lowering rawOutputMaxBytes below the retention + // budget) must not smuggle an over-cap parse through the untruncated path. + const { ctx, subprocess } = await setup({ config: { rawOutputMaxBytes: 16 } }) + subprocess.handler = () => runResult(`${'x'.repeat(64)}\n`) const result = await call(ctx, 'grep', { pattern: 'x' }) expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_RAW_OUTPUT_OVERFLOW' } }) expect(text(result)).toContain('narrow pattern, path, or include') }) it('fails with SEARCH_RAW_OUTPUT_OVERFLOW when truncated stdout has no spill path', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('', { stdout: { text: 'partial', truncated: true } }) + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('', { stdout: { text: 'partial', lossy: true } }) const result = await call(ctx, 'grep', { pattern: 'x' }) expect(result.error).toMatchObject({ info: { code: 'SEARCH_RAW_OUTPUT_OVERFLOW' } }) }) @@ -614,8 +663,8 @@ describe('cross-directory sampling', () => { describe('glob results', () => { it('lists workdir-relative paths (absolute output under the workdir is relativized)', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('/sessions/s1/src/a.ts\n/elsewhere/b.ts\nrel/c.ts\n') + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('/sessions/s1/src/a.ts\n/elsewhere/b.ts\nrel/c.ts\n') const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/sessions/s1') }) if (result.isError) throw new Error('expected glob success') expect(result.value).toEqual({ root: '.', paths: [join('src', 'a.ts'), '/elsewhere/b.ts', 'rel/c.ts'] }) @@ -628,23 +677,30 @@ describe('glob results', () => { expect(text(await call(ctx, 'glob', { pattern: '*', path: ' ' }))).toContain('path must be a non-empty string') }) - it('threads a valid path through to the command as the quoted search root', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('sub/a.ts\n') + it('threads a valid path through to the spawn as the plain search root element', async () => { + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('sub/a.ts\n') const result = await call(ctx, 'glob', { pattern: '*.ts', path: 'sub' }) expect(result.isError).toBe(false) - expect(bash.specs[0]?.command).toContain("-- 'sub'") + expect(subprocess.spawns[0]?.argv).toEqual([rgPath, '--files', '--glob=*.ts', '--sort=modified', '--no-ignore', '--hidden', + '--glob=!**/.git', '--glob=!**/.git/**', + '--glob=!**/.svn', '--glob=!**/.svn/**', + '--glob=!**/.hg', '--glob=!**/.hg/**', + '--glob=!**/.bzr', '--glob=!**/.bzr/**', + '--glob=!**/.jj', '--glob=!**/.jj/**', + '--glob=!**/.sl', '--glob=!**/.sl/**', + '--', 'sub']) }) it('caps at globMaxResults and saves the FULL sorted list through spillStore', async () => { - const { ctx, bash, spill } = await setup({ config: { globMaxResults: 2 }, spill: true }) + const { ctx, subprocess, spill } = await setup({ config: { globMaxResults: 2 }, spill: true }) ctx.on('tools/post-execute', async () => ({ kind: 'accept', additionalContexts: [createUserMessage({ content: [{ type: 'text', text: 'glob context' }], source: { kind: 'plugin', plugin: 'test' }, })], })) - bash.handler = () => runResult('a.ts\nb.ts\nc.ts\nd.ts\n') + subprocess.handler = () => runResult('a.ts\nb.ts\nc.ts\nd.ts\n') const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') }) expect(result.isError).toBe(false) if (result.isError) throw new Error('expected glob success') @@ -665,8 +721,8 @@ describe('glob results', () => { // The shipped failure: `*` matches the whole tree, mtime order puts one // freshly-unpacked subtree first, and a head-of-3 reads like the entire // workspace. The sample reaches every top-level entry instead. - const { ctx, bash } = await setup({ config: { globMaxResults: 3 } }) - bash.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts', 'guide/e.md', 'top.txt'].join('\n')) + const { ctx, subprocess } = await setup({ config: { globMaxResults: 3 } }) + subprocess.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts', 'guide/e.md', 'top.txt'].join('\n')) const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') }) expect(text(result)).toBe('vendor/a.ts\nsrc/d.ts\nguide/e.md\n\n' + '(Showing 3 of 6 paths, sampled across 3 of the 4 top-level entries this pattern matched ' @@ -675,18 +731,18 @@ describe('glob results', () => { }) it('keeps the modification-time head when over-cap sampling is disabled', async () => { - const { ctx, bash } = await setup({ + const { ctx, subprocess } = await setup({ config: { globMaxResults: 3, sampleOverCapGlobResults: false }, }) - bash.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts', 'guide/e.md'].join('\n')) + subprocess.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts', 'guide/e.md'].join('\n')) expect(text(await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') }))) .toBe('vendor/a.ts\nvendor/b.ts\nvendor/c.ts\n\n' + '(Showing 3 of 5 paths. The complete result could not be saved; narrow pattern or path to see more.)') }) it('samples relative to the explicit search root instead of its workdir prefix', async () => { - const { ctx, bash } = await setup({ config: { globMaxResults: 3 } }) - bash.handler = () => runResult([ + const { ctx, subprocess } = await setup({ config: { globMaxResults: 3 } }) + subprocess.handler = () => runResult([ 'workspace/vendor/a.ts', 'workspace/vendor/b.ts', 'workspace/source/c.ts', @@ -698,8 +754,8 @@ describe('glob results', () => { }) it('samples relative to an absolute search root after workdir display conversion', async () => { - const { ctx, bash } = await setup({ config: { globMaxResults: 3 } }) - bash.handler = () => runResult([ + const { ctx, subprocess } = await setup({ config: { globMaxResults: 3 } }) + subprocess.handler = () => runResult([ '/w/workspace/vendor/a.ts', '/w/workspace/vendor/b.ts', '/w/workspace/source/c.ts', @@ -711,8 +767,8 @@ describe('glob results', () => { }) it('drops the narrowing hint when the sample reaches every top-level entry', async () => { - const { ctx, bash } = await setup({ config: { globMaxResults: 3 } }) - bash.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts'].join('\n')) + const { ctx, subprocess } = await setup({ config: { globMaxResults: 3 } }) + subprocess.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts'].join('\n')) expect(text(await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') }))) .toBe('vendor/a.ts\nvendor/b.ts\nsrc/d.ts\n\n' + '(Showing 3 of 4 paths, sampled across 2 of the 2 top-level entries this pattern matched ' @@ -721,34 +777,34 @@ describe('glob results', () => { }) it('keeps modification-time order untouched when the whole result fits', async () => { - const { ctx, bash } = await setup({ config: { globMaxResults: 4 } }) - bash.handler = () => runResult('vendor/a.ts\nvendor/b.ts\nsrc/c.ts\n') + const { ctx, subprocess } = await setup({ config: { globMaxResults: 4 } }) + subprocess.handler = () => runResult('vendor/a.ts\nvendor/b.ts\nsrc/c.ts\n') expect(text(await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') }))) .toBe('vendor/a.ts\nvendor/b.ts\nsrc/c.ts') }) it('keeps the plain footer for a flat result, where the sample is the modification-time head', async () => { - const { ctx, bash } = await setup({ config: { globMaxResults: 2 } }) - bash.handler = () => runResult('a.ts\nb.ts\nc.ts\n') + const { ctx, subprocess } = await setup({ config: { globMaxResults: 2 } }) + subprocess.handler = () => runResult('a.ts\nb.ts\nc.ts\n') expect(text(await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') }))) .toBe('a.ts\nb.ts\n\n(Showing 2 of 3 paths. The complete result could not be saved; narrow pattern or path to see more.)') }) it('does not create a spill file when the result fits inline', async () => { - const { ctx, bash, spill } = await setup({ spill: true }) - bash.handler = () => runResult('a.ts\nb.ts\n') + const { ctx, subprocess, spill } = await setup({ spill: true }) + subprocess.handler = () => runResult('a.ts\nb.ts\n') const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') }) expect(text(result)).toBe('a.ts\nb.ts') expect(spill?.saves).toHaveLength(0) }) it('preserves a downstream canonical value replacement instead of spilling the old value', async () => { - const { ctx, bash, spill } = await setup({ config: { globMaxResults: 1 }, spill: true }) + const { ctx, subprocess, spill } = await setup({ config: { globMaxResults: 1 }, spill: true }) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: { root: '.', paths: ['replacement-a.ts', 'replacement-b.ts'] }, })) - bash.handler = () => runResult('old-a.ts\nold-b.ts\n') + subprocess.handler = () => runResult('old-a.ts\nold-b.ts\n') const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') }) @@ -760,8 +816,8 @@ describe('glob results', () => { }) it('keeps the full nested Code value without creating a surface spill', async () => { - const { ctx, bash, spill } = await setup({ config: { globMaxResults: 2 }, spill: true }) - bash.handler = () => runResult('a.ts\nb.ts\nc.ts\nd.ts\n') + const { ctx, subprocess, spill } = await setup({ config: { globMaxResults: 2 }, spill: true }) + subprocess.handler = () => runResult('a.ts\nb.ts\nc.ts\nd.ts\n') const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w'), parent: Symbol('run_code') as ToolExecutionToken, @@ -777,9 +833,9 @@ describe('glob results', () => { ['saveText fails', { fail: true, spill: true, ownerless: false }], ['no session owner', { fail: false, spill: true, ownerless: true }], ])('keeps the inline page and reports the unsaved remainder when %s', async (_label, mode) => { - const { ctx, bash, spill } = await setup({ config: { globMaxResults: 1 }, spill: mode.spill }) + const { ctx, subprocess, spill } = await setup({ config: { globMaxResults: 1 }, spill: mode.spill }) if (mode.fail && spill) spill.failWith = new Error('disk full') - bash.handler = () => runResult('a.ts\nb.ts\n') + subprocess.handler = () => runResult('a.ts\nb.ts\n') const result = await call(ctx, 'glob', { pattern: '*' }, mode.ownerless ? {} : { agent: agent('/w') }) expect(result.isError).toBe(false) // spill unavailability never fails the search expect(text(result)).toBe('a.ts\n\n(Showing 1 of 2 paths. The complete result could not be saved; narrow pattern or path to see more.)') @@ -788,8 +844,8 @@ describe('glob results', () => { describe('grep results', () => { it('groups matches by file with line numbers', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult([ + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult([ JSON.stringify({ type: 'begin', data: { path: { text: 'a.ts' } } }), matchLine('a.ts', 3, 'const x = 1\n'), matchLine('a.ts', 9, 'const y = 2\n'), @@ -812,23 +868,23 @@ describe('grep results', () => { }) it('reports a single match in the singular', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult(`${matchLine('a.ts', 1, 'hit')}\n`) + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult(`${matchLine('a.ts', 1, 'hit')}\n`) expect(text(await call(ctx, 'grep', { pattern: 'hit' }))).toBe('Found 1 match\n\na.ts\nLine 1: hit') }) it('relativizes absolute match paths against the resolved workdir', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult(`${matchLine('/sessions/s1/deep/a.ts', 2, 'hit')}\n`) + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult(`${matchLine('/sessions/s1/deep/a.ts', 2, 'hit')}\n`) const result = await call(ctx, 'grep', { pattern: 'hit', path: '/sessions/s1' }, { agent: agent('/sessions/s1') }) expect(text(result)).toContain(`${join('deep', 'a.ts')}\nLine 2: hit`) }) it('previews a long matched line at grepMaxLineBytes preserving UTF-8', async () => { - const { ctx, bash } = await setup({ config: { grepMaxLineBytes: 7 } }) + const { ctx, subprocess } = await setup({ config: { grepMaxLineBytes: 7 } }) // 'héllo wörld' cut at 7 bytes lands mid-'é'? h(1)é(2)l(1)l(1)o(1)=6, space=7 → clean cut at 7. // Use a multibyte straddle instead: 'aé' repeated — cut at 7 bytes: a(1)é(2)a(1)é(2)=6 +a(1)=7 → next é straddles: trimmed. - bash.handler = () => runResult(`${matchLine('a.txt', 1, 'aéaéaéaé')}\n`) + subprocess.handler = () => runResult(`${matchLine('a.txt', 1, 'aéaéaéaé')}\n`) const result = await call(ctx, 'grep', { pattern: 'a' }) if (result.isError) throw new Error('expected grep success') expect(result.value).toEqual({ matches: [{ path: 'a.txt', lineNumber: 1, line: 'aéaéaéaé' }] }) @@ -836,9 +892,9 @@ describe('grep results', () => { }) it('renders a non-UTF-8 line (rg bytes form) as a placeholder instead of failing', async () => { - const { ctx, bash } = await setup() + const { ctx, subprocess } = await setup() const record = JSON.stringify({ type: 'match', data: { path: { text: 'bin.dat' }, lines: { bytes: 'AAECww==' }, line_number: 4 } }) - bash.handler = () => runResult(`${record}\n`) + subprocess.handler = () => runResult(`${record}\n`) expect(text(await call(ctx, 'grep', { pattern: 'x' }))).toContain('Line 4: (line is not valid UTF-8)') }) @@ -848,14 +904,14 @@ describe('grep results', () => { }) it('caps at grepMaxMatches and spills the full formatted match list', async () => { - const { ctx, bash, spill } = await setup({ config: { grepMaxMatches: 2 }, spill: true }) + const { ctx, subprocess, spill } = await setup({ config: { grepMaxMatches: 2 }, spill: true }) ctx.on('tools/post-execute', async () => ({ kind: 'accept', additionalContexts: [createUserMessage({ content: [{ type: 'text', text: 'grep context' }], source: { kind: 'plugin', plugin: 'test' }, })], })) - bash.handler = () => runResult([ + subprocess.handler = () => runResult([ matchLine('a.ts', 1, 'one'), matchLine('a.ts', 2, 'two'), matchLine('b.ts', 3, 'three'), @@ -880,7 +936,7 @@ describe('grep results', () => { }) it('preserves a downstream canonical value replacement instead of spilling the old matches', async () => { - const { ctx, bash, spill } = await setup({ config: { grepMaxMatches: 1 }, spill: true }) + const { ctx, subprocess, spill } = await setup({ config: { grepMaxMatches: 1 }, spill: true }) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: { @@ -890,7 +946,7 @@ describe('grep results', () => { ], }, })) - bash.handler = () => runResult(`${matchLine('old.ts', 1, 'old')}\n`) + subprocess.handler = () => runResult(`${matchLine('old.ts', 1, 'old')}\n`) const result = await call(ctx, 'grep', { pattern: 'old' }, { agent: agent('/w') }) @@ -907,8 +963,8 @@ describe('grep results', () => { }) it('keeps every nested Code match in the value without creating a surface spill', async () => { - const { ctx, bash, spill } = await setup({ config: { grepMaxMatches: 1 }, spill: true }) - bash.handler = () => runResult(`${matchLine('a.ts', 1, 'one')}\n${matchLine('b.ts', 2, 'two')}\n`) + const { ctx, subprocess, spill } = await setup({ config: { grepMaxMatches: 1 }, spill: true }) + subprocess.handler = () => runResult(`${matchLine('a.ts', 1, 'one')}\n${matchLine('b.ts', 2, 'two')}\n`) const result = await call(ctx, 'grep', { pattern: 'o' }, { agent: agent('/w'), parent: Symbol('run_code') as ToolExecutionToken, @@ -925,8 +981,8 @@ describe('grep results', () => { }) it('reports the unsaved remainder when capped with no spill backend', async () => { - const { ctx, bash } = await setup({ config: { grepMaxMatches: 1 } }) - bash.handler = () => runResult(`${matchLine('a.ts', 1, 'one')}\n${matchLine('a.ts', 2, 'two')}\n`) + const { ctx, subprocess } = await setup({ config: { grepMaxMatches: 1 } }) + subprocess.handler = () => runResult(`${matchLine('a.ts', 1, 'one')}\n${matchLine('a.ts', 2, 'two')}\n`) const result = await call(ctx, 'grep', { pattern: 'o' }, { agent: agent('/w') }) expect(result.isError).toBe(false) expect(text(result)).toBe('Found 1 of 2 matches\n\na.ts\nLine 1: one\n\n(The complete result could not be saved; narrow pattern, path, or include to see more.)') @@ -942,8 +998,8 @@ describe('grep results', () => { }) it('accepts a whitespace-only pattern (a legitimate regex) and brace alternation in include', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('', { exitCode: 1 }) + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('', { exitCode: 1 }) const result = await call(ctx, 'grep', { pattern: ' ', include: '*.{ts,tsx}' }) expect(result.isError).toBe(false) }) @@ -960,8 +1016,8 @@ describe('rg --json transport failures (SEARCH_FAILED)', () => { ['a match record with no line content', JSON.stringify({ type: 'match', data: { path: { text: 'a.ts' }, line_number: 1 } })], ['a match record with neither text nor bytes', JSON.stringify({ type: 'match', data: { path: { text: 'a.ts' }, lines: {}, line_number: 1 } })], ])('%s fails the search', async (_label, line) => { - const { ctx, bash } = await setup() - bash.handler = () => runResult(`${line}\n`) + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult(`${line}\n`) const result = await call(ctx, 'grep', { pattern: 'x' }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } }) @@ -969,13 +1025,16 @@ describe('rg --json transport failures (SEARCH_FAILED)', () => { }) describe('the no-background-task invariant', () => { - it('never calls ctx.bash.start() across successful and failed searches', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('a.ts\n') + it('settles every spawned search handle across successful and failed searches', async () => { + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('a.ts\n') await call(ctx, 'glob', { pattern: '*' }) - bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'boom', truncated: false } }) + subprocess.handler = () => runResult('', { exitCode: 2, stderr: { text: 'boom' } }) await call(ctx, 'grep', { pattern: 'x' }) - expect(bash.startCalls).toBe(0) + // One foreground spawn per call, each awaited to settlement before the + // tool returns — the searches never leave a background handle running. + expect(subprocess.spawns).toHaveLength(2) + expect(subprocess.handles.every(handle => handle.settled)).toBe(true) }) }) @@ -991,8 +1050,8 @@ describe('presentation', () => { }) it('grep projects a search card from a real execute, grouped by file with total and truncation', async () => { - const { ctx, bash } = await setup({ config: { grepMaxMatches: 2 } }) - bash.handler = () => runResult([ + const { ctx, subprocess } = await setup({ config: { grepMaxMatches: 2 } }) + subprocess.handler = () => runResult([ matchLine('a.ts', 1, 'one'), matchLine('a.ts', 2, 'two'), matchLine('b.ts', 3, 'three'), @@ -1018,8 +1077,8 @@ describe('presentation', () => { }) it('glob projects a search card from a real execute, a flat path list with total and truncation', async () => { - const { ctx, bash } = await setup({ config: { globMaxResults: 2 } }) - bash.handler = () => runResult('a.ts\nb.ts\nc.ts\n') + const { ctx, subprocess } = await setup({ config: { globMaxResults: 2 } }) + subprocess.handler = () => runResult('a.ts\nb.ts\nc.ts\n') const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') }) if (result.isError) throw new Error('expected glob success') expect(result.meta).toEqual({ shape: 'paths', paths: ['a.ts', 'b.ts'], truncated: true, total: 3 }) @@ -1028,8 +1087,8 @@ describe('presentation', () => { }) it('nested Code dispatch computes no meta, so presentResult falls back to the generic card', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult(`${matchLine('a.ts', 1, 'one')}\n`) + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult(`${matchLine('a.ts', 1, 'one')}\n`) const result = await call(ctx, 'grep', { pattern: 'o' }, { agent: agent('/w'), parent: Symbol('run_code') as ToolExecutionToken, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34eb7b8f87..8b38715d30 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2931,6 +2931,9 @@ importers: packages/fs/tool-fs-search: dependencies: + '@vscode/ripgrep': + specifier: ^1.18.0 + version: 1.18.0 schemastery: specifier: ^3.18.0 version: 3.18.0 @@ -2938,12 +2941,6 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent - '@deepseek-ai/dsh-bash': - specifier: workspace:^ - version: link:../../bash/bash - '@deepseek-ai/dsh-bash-local': - specifier: workspace:^ - version: link:../../bash/bash-local '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -2959,6 +2956,9 @@ importers: '@deepseek-ai/dsh-spill': specifier: workspace:^ version: link:../../spill/spill + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess '@deepseek-ai/dsh-subprocess-local': specifier: workspace:^ version: link:../../subprocess/subprocess-local @@ -9010,6 +9010,69 @@ packages: '@vitest/utils@4.1.8': resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} + '@vscode/ripgrep-darwin-arm64@1.18.0': + resolution: {integrity: sha512-r3ktHSvbFycQNF6sl7sNDPocpsI7J+mEzh1IaZFkY0spm3k2Z9t8hPAeOK7+p0l6p6/swkQC14XWX01low+94Q==} + cpu: [arm64] + os: [darwin] + + '@vscode/ripgrep-darwin-x64@1.18.0': + resolution: {integrity: sha512-25b4gWbL138dGuQU244ebCKKc0q05ULBMoFSz9oAEUHNeqK/lOJViDS7DRvbDazzAzSEdan391Znks/R5mkaTQ==} + cpu: [x64] + os: [darwin] + + '@vscode/ripgrep-linux-arm64@1.18.0': + resolution: {integrity: sha512-lQ/5zTG++U0E3IhVgS4EPTTn/U4okncaRMM5GOFfOYZywS4nuD31GhkHbNYlDk5CuDC68+hYJ0/eQeyCKJDA+g==} + cpu: [arm64] + os: [linux] + + '@vscode/ripgrep-linux-arm@1.18.0': + resolution: {integrity: sha512-GDAvufNDHu8zqLEmXstalQF0Wh6wQvdsBi/Vg3Yi3CK4a8XoFXqqXVEHEZ9xQz3t0NfoSEc9JbvK9DDS6FxyxQ==} + cpu: [arm] + os: [linux] + + '@vscode/ripgrep-linux-ia32@1.18.0': + resolution: {integrity: sha512-YWLkSUtFd4Jh5EepIhA9RJSfv3uMAVMo+2rBIGHPBnvgLrZciIs2cDKei1/p6Wc/aCzUoHyMAg2R6tw4ZCBKGg==} + cpu: [ia32] + os: [linux] + + '@vscode/ripgrep-linux-ppc64@1.18.0': + resolution: {integrity: sha512-quXVY8fwQ8O/lvU1yrSqSl3jlUzysRSb+AfUfCL/tRtphxsKlFvPAejryZ6vg4Bgvn8XL74xb4qMCDmWgYrT5w==} + cpu: [ppc64] + os: [linux] + + '@vscode/ripgrep-linux-riscv64@1.18.0': + resolution: {integrity: sha512-f5kBQBrWfQt8Q7OhSORuNDei5dkYagBj3y4jImSUXGMy8B/Ke7SltSRcUtjPv166FAFfHCAmWuZp3+cWnX2/Vw==} + cpu: [riscv64] + os: [linux] + + '@vscode/ripgrep-linux-s390x@1.18.0': + resolution: {integrity: sha512-rTOcJFGGcl2c07RUOWUo4U1ndnemKhY6A9hnMB18uk7jSgJc0d/QLBGWMWpumdtoJtpizn/wIv5mXIisJukusQ==} + cpu: [s390x] + os: [linux] + + '@vscode/ripgrep-linux-x64@1.18.0': + resolution: {integrity: sha512-mQ3bVrUpnD2vs7QT0vX90Lt0cnUq467uFtEktIdsJJmW296RoSULRGqWgzG1AKxyBpNDD6l4ZO4qKf6SgyC23Q==} + cpu: [x64] + os: [linux] + + '@vscode/ripgrep-win32-arm64@1.18.0': + resolution: {integrity: sha512-vfTIjq1OHnzUjxZcHVQAMbnggp8dpGf+0QKFOZHwWPqFwXxQC8eCWM+5NUdoJ6yrElCeMzoUTXoK/LdZaniB+Q==} + cpu: [arm64] + os: [win32] + + '@vscode/ripgrep-win32-ia32@1.18.0': + resolution: {integrity: sha512-//rfAE+BOw5AC2EMmepmiE36jUuevtQYNQqqlw1s3m9FlRxjxEut97RkRPHAu9BG4mSojatZx+kXZXNdyI9caQ==} + cpu: [ia32] + os: [win32] + + '@vscode/ripgrep-win32-x64@1.18.0': + resolution: {integrity: sha512-KNPvtElldqILHdnAetujPaowkNbpqJy3ssIGGN6F6Kve9Qi+nNLI2DN01O83JjCEVQbCzl8Ov3QZ9Eov3BR8Dg==} + cpu: [x64] + os: [win32] + + '@vscode/ripgrep@1.18.0': + resolution: {integrity: sha512-ns5lWe44tSfbTMbVUsyB+I1819PVSw4AdpgK0RNkzfWfwy6+3IUNSxwSrfTno1/oWaS/hERNz+XLWVyga2aJBQ==} + '@vue/compiler-core@3.5.39': resolution: {integrity: sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==} @@ -14110,6 +14173,57 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@vscode/ripgrep-darwin-arm64@1.18.0': + optional: true + + '@vscode/ripgrep-darwin-x64@1.18.0': + optional: true + + '@vscode/ripgrep-linux-arm64@1.18.0': + optional: true + + '@vscode/ripgrep-linux-arm@1.18.0': + optional: true + + '@vscode/ripgrep-linux-ia32@1.18.0': + optional: true + + '@vscode/ripgrep-linux-ppc64@1.18.0': + optional: true + + '@vscode/ripgrep-linux-riscv64@1.18.0': + optional: true + + '@vscode/ripgrep-linux-s390x@1.18.0': + optional: true + + '@vscode/ripgrep-linux-x64@1.18.0': + optional: true + + '@vscode/ripgrep-win32-arm64@1.18.0': + optional: true + + '@vscode/ripgrep-win32-ia32@1.18.0': + optional: true + + '@vscode/ripgrep-win32-x64@1.18.0': + optional: true + + '@vscode/ripgrep@1.18.0': + optionalDependencies: + '@vscode/ripgrep-darwin-arm64': 1.18.0 + '@vscode/ripgrep-darwin-x64': 1.18.0 + '@vscode/ripgrep-linux-arm': 1.18.0 + '@vscode/ripgrep-linux-arm64': 1.18.0 + '@vscode/ripgrep-linux-ia32': 1.18.0 + '@vscode/ripgrep-linux-ppc64': 1.18.0 + '@vscode/ripgrep-linux-riscv64': 1.18.0 + '@vscode/ripgrep-linux-s390x': 1.18.0 + '@vscode/ripgrep-linux-x64': 1.18.0 + '@vscode/ripgrep-win32-arm64': 1.18.0 + '@vscode/ripgrep-win32-ia32': 1.18.0 + '@vscode/ripgrep-win32-x64': 1.18.0 + '@vue/compiler-core@3.5.39': dependencies: '@babel/parser': 7.29.7 diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index 5306f9a003..5cb912c9ff 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -157,6 +157,30 @@ function loadWorkspaceManifests(): { manifests: Map; names: Se return { manifests, names } } +type VirtualManifest = Manifest & { license?: string; repository?: string | { url?: string }; homepage?: string } + +/** + * Resolve one package's manifest inside a pnpm virtual store. The prefix scan + * matches ordinary `@scope+name@version` directory names; pnpm 11 truncates + * long names (a peer-suffixed name past the length limit becomes + * `_`), so a content scan falls back over the whole store when + * the prefix misses. + */ +function virtualManifest(virtual: string, name: string): VirtualManifest | undefined { + const prefix = `${name.replace('/', '+')}@` + const entry = readdirSync(virtual).find(dir => dir.startsWith(prefix)) + if (entry !== undefined) { + return JSON.parse(readFileSync(resolve(virtual, entry, 'node_modules', name, 'package.json'), 'utf8')) as VirtualManifest + } + for (const dir of readdirSync(virtual)) { + const candidate = resolve(virtual, dir, 'node_modules', name, 'package.json') + if (existsSync(candidate)) { + return JSON.parse(readFileSync(candidate, 'utf8')) as VirtualManifest + } + } + return undefined +} + /** License and repository URL for an installed external package, from the pnpm store. */ function installedMetadata(name: string): { license: string; repo: string } { const override = OVERRIDES[name] @@ -171,11 +195,8 @@ function installedMetadata(name: string): { license: string; repo: string } { } const virtual = resolve(root, store, '.pnpm') if (!existsSync(virtual)) continue - const prefix = `${name.replace('/', '+')}@` - const entry = readdirSync(virtual).find(dir => dir.startsWith(prefix)) - if (entry === undefined) continue - manifest = JSON.parse(readFileSync(resolve(virtual, entry, 'node_modules', name, 'package.json'), 'utf8')) as typeof manifest - break + manifest = virtualManifest(virtual, name) + if (manifest !== undefined) break } const license = override?.license ?? manifest?.license const rawRepo = typeof manifest?.repository === 'string' ? manifest.repository : manifest?.repository?.url ?? manifest?.homepage diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index a77bdb6d95..3db63b4271 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -16,8 +16,6 @@ import SessionQuerySqlite from '@deepseek-ai/dsh-session-query-sqlite' import GoalService from '@deepseek-ai/dsh-goal' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' -import { BashExecutor } from '@deepseek-ai/dsh-bash' -import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' @@ -55,44 +53,6 @@ import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' const root = resolve(import.meta.dirname, '..') const OUT = 'docs/tool-catalog.md' -const CATALOG_RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1' - -/** - * Minimal bash service for harvesting `dsh-tool-fs-search` schemas. The search - * plugin now probes `rg` at registration time, but the generated catalog must - * remain independent of the host PATH and never execute a real search. - */ -class CatalogSearchBashExecutor extends BashExecutor { - override resolve(request: BashExecRequest): BashExecSpec { - return { - command: request.command, - workdir: request.workdir ?? root, - timeoutMs: request.timeoutMs ?? 60_000, - stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, - signal: request.signal, - sandboxPolicy: request.sandboxPolicy, - } - } - - override run(spec: BashExecSpec): Promise { - if (spec.command !== CATALOG_RG_PROBE_COMMAND) { - throw new Error(`gen-tool-catalog: unexpected search bash command during schema harvest: ${spec.command}`) - } - return Promise.resolve({ - exitCode: 0, - signal: null, - timedOut: false, - aborted: false, - timeoutMs: spec.timeoutMs, - stdout: { text: '', truncated: false }, - stderr: { text: '', truncated: false }, - }) - } - - override start(): BashProcess { - throw new Error('gen-tool-catalog: search schema harvest must not start background processes') - } -} /** * Register the descriptor needed to mount schema-producing consumers. Declares @@ -264,19 +224,19 @@ const TOOL_PACKAGES: ToolPackage[] = [ pkg: '@deepseek-ai/dsh-tool-fs-search', dir: 'tool-fs-search', source: 'packages/fs/tool-fs-search/src/index.ts', - requires: ['ctx.tools', 'ctx.bash', 'ctx.systemPrompt'], + requires: ['ctx.tools', 'ctx.subprocess', 'ctx.systemPrompt'], writes: ['tool/call', 'tool/result'], async mount(ctx) { - // The tools inject `bash` (search executes fixed `rg` commands through - // the executor seam, not ctx.fs). Use a catalog-only executor so the - // registration-time `rg` probe stays deterministic and the generator - // never depends on the host PATH. `ctx.spillStore` is optional (read via - // ctx.get) and does not affect the schemas, so no spill backend is mounted. - await ctx.plugin(CatalogSearchBashExecutor) + // The tools inject `subprocess` (search spawns the packaged ripgrep + // binary through the seam, not ctx.fs); registration itself never + // spawns, so the real local service is inert here. `ctx.spillStore` is + // optional (read via ctx.get) and does not affect the schemas, so no + // spill backend is mounted. + await ctx.plugin(LocalSubprocessService) await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: true }) }, note: - 'glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.', + 'glob and grep are unconditional discovery tools that spawn the packaged ripgrep binary (`@vscode/ripgrep`) through ctx.subprocess as ordinary foreground calls (never background tasks) — no host `rg` install and no shell layer. The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.', }, { pkg: '@deepseek-ai/dsh-tool-pty', From 7206c6019a1be093a0f9aca293a34f577494ceb8 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 1 Aug 2026 18:15:11 +0800 Subject: [PATCH 20/52] chore(knip): drop the now-unused rg binary exemption tool-fs-search no longer spawns rg from PATH after the packaged-binary switch, so its per-workspace ignoreBinaries entry is stale. --- knip.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/knip.json b/knip.json index 99b152738d..30b1821cd3 100644 --- a/knip.json +++ b/knip.json @@ -566,9 +566,6 @@ "project": [ "src/**/*.ts", "tests/**/*.ts" - ], - "ignoreBinaries": [ - "rg" ] }, "packages/mcp/mcp-client": { From 00148eea9732fe73ae4fdaf2932d35c4e708bf81 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 1 Aug 2026 19:13:42 +0800 Subject: [PATCH 21/52] fix(scripts): normalize manifest glob paths in the notices generator Node's fs.globSync returns OS-native separators: on Windows the backslash paths failed the /-suffixed DEV_ONLY_AREAS prefix match in tierExternalDeps, silently tiering dev-area manifests (test-runtime, support/*, apps/*) as runtime dependencies. Normalize to / at ingestion so the generated notices are platform-independent. --- THIRD_PARTY_NOTICES.md | 8 ++++---- scripts/gen-third-party-notices.ts | 13 ++++++++++--- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 1b14996c3f..f4152cb8a0 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -47,8 +47,6 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`@opentelemetry/sdk-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | | [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT | | [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT | -| [`@testing-library/dom`](https://github.com/testing-library/dom-testing-library) | MIT | -| [`@testing-library/react`](https://github.com/testing-library/react-testing-library) | MIT | | [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT | | [`anser`](https://github.com/IonicaBizau/anser) | MIT | | [`chokidar`](https://github.com/paulmillr/chokidar) | MIT | @@ -57,7 +55,6 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause | | [`dotenv`](https://github.com/motdotla/dotenv) | BSD-2-Clause | | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | -| [`execa`](https://github.com/sindresorhus/execa) | MIT | | [`handlebars`](https://github.com/handlebars-lang/handlebars.js) | MIT | | [`immer`](https://github.com/immerjs/immer) | MIT | | [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT | @@ -83,7 +80,6 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`turndown`](https://github.com/mixmark-io/turndown) | MIT | | [`typescript`](https://github.com/microsoft/TypeScript) | Apache-2.0 | | [`use-sync-external-store`](https://github.com/facebook/react) | MIT | -| [`vitest`](https://github.com/vitest-dev/vitest) | MIT | | [`yaml`](https://github.com/eemeli/yaml) | ISC | | [`zod`](https://github.com/colinhacks/zod) | MIT | | [`zustand`](https://github.com/pmndrs/zustand) | MIT | @@ -103,6 +99,8 @@ External packages **directly declared** only by repository tooling, test infrast | [`@modelcontextprotocol/server-everything`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 | | [`@modelcontextprotocol/server-filesystem`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 | | [`@stylistic/eslint-plugin`](https://github.com/eslint-stylistic/eslint-stylistic) | MIT | +| [`@testing-library/dom`](https://github.com/testing-library/dom-testing-library) | MIT | +| [`@testing-library/react`](https://github.com/testing-library/react-testing-library) | MIT | | [`@types/babel__code-frame`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | @@ -125,6 +123,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`esbuild`](https://github.com/evanw/esbuild) | MIT | | [`eslint`](https://github.com/eslint/eslint) | MIT | | [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only | +| [`execa`](https://github.com/sindresorhus/execa) | MIT | | [`fast-check`](https://github.com/dubzzz/fast-check) | MIT | | [`jscpd`](https://github.com/kucherenko/jscpd) | MIT | | [`jsdom`](https://github.com/jsdom/jsdom) | MIT | @@ -144,6 +143,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`vite-tsconfig-paths`](https://github.com/aleclarson/vite-tsconfig-paths) | MIT | | [`vitepress`](https://github.com/vuejs/vitepress) | MIT | | [`vitepress-plugin-mermaid`](https://github.com/emersonbottero/vitepress-plugin-mermaid) | MIT | +| [`vitest`](https://github.com/vitest-dev/vitest) | MIT | `eslint-plugin-sonarjs` (LGPL-3.0-only) and `lightningcss` (MPL-2.0) run only as development tooling; their code is not linked into or distributed with any DeepSeek Harness artifact. diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index 5cb912c9ff..a4a1f4c343 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -141,15 +141,22 @@ function workspaceMembers(rel: string): string[] { return declared.map(member => String(member)) } -/** Every workspace manifest, keyed by path, plus the set of workspace package names. */ +/** + * Every workspace manifest, keyed by repository-relative path, plus the set of + * workspace package names. Paths are normalized to `/` at ingestion: Node's + * `fs.globSync` returns OS-native separators, and the area matching in + * `tierExternalDeps` compares `/`-suffixed prefixes, so Windows backslashes + * would silently push dev-area manifests into the runtime tier. + */ function loadWorkspaceManifests(): { manifests: Map; names: Set } { const patterns = manifestPatterns(workspaceMembers('pnpm-workspace.yaml'), workspaceMembers('native/landlock-run/pnpm-workspace.yaml')) const manifests = new Map() const names = new Set() for (const pattern of patterns) { for (const path of globSync(pattern, { cwd: root })) { - const manifest = readManifest(path) - manifests.set(path, manifest) + const normalized = path.replaceAll('\\', '/') + const manifest = readManifest(normalized) + manifests.set(normalized, manifest) if (manifest.name !== undefined) names.add(manifest.name) } } From 36700f69169d4ea7d557c634f62581c8d852782d Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 1 Aug 2026 19:17:59 +0800 Subject: [PATCH 22/52] docs(note): record the notices-generator fixes behind the new dependency --- .../architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml | 4 ++-- .../architecture/2026-08-01-packaged-ripgrep-search.md | 2 ++ .../architecture/2026-08-01-packaged-ripgrep-search.zh.md | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml index fe6bb60e68..df6948209a 100644 --- a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md -2026-08-01-packaged-ripgrep-search.md: e43354ff8e4dde0480a6c07816fc112810197234 -2026-08-01-packaged-ripgrep-search.zh.md: 55498d366a2171a178cf9d0d1004b6fe946a7281 +2026-08-01-packaged-ripgrep-search.md: 0470623163ff7686bdd77ca715bac68c9a466d2b +2026-08-01-packaged-ripgrep-search.zh.md: fe53643aff139615d49edd2e3b27d14428cd2029 diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md index e43354ff8e..0470623163 100644 --- a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md @@ -32,3 +32,5 @@ The `fs-glob-sampling` ACP snapshot scenario now executes the real packaged bina - The shell-string attack surface is gone: hostile patterns are inert argv elements, pinned by the integration suite, which now runs on Windows too (it previously self-skipped without a system `rg`). - Load-time failure modes changed: a broken subprocess seam now fails the first search call (`SEARCH_FAILED`) instead of failing plugin load through the probe; a missing binary is a launch failure with the packaged path, not a PATH problem. - The integration suite's fixture dropped a filename Windows cannot represent (`"` in a name), keeping the suite replayable on every platform. +- Regenerating `THIRD_PARTY_NOTICES.md` surfaced a latent generator bug the new dependency made visible: Node's `fs.globSync` returns OS-native separators, so on Windows the `/`-suffixed dev-area prefixes in the notices tiering never matched and dev-only packages (test tooling, support leaves) were mis-tiered as runtime. The generator now normalizes manifest paths at ingestion, and the notices are platform-independent. +- The `@vscode/ripgrep` dependency adds its MIT row to the runtime tier, and pnpm 11's truncated virtual-store directory names needed a content-scan fallback in the notices generator's metadata lookup. diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md index 55498d366a..fe53643aff 100644 --- a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md @@ -32,3 +32,5 @@ Status: implemented - shell 字符串攻击面消失:恶意模式只是惰性 argv 元素,由集成套件钉住;该套件现在也在 Windows 上运行(此前没有系统 `rg` 时它自行跳过)。 - 加载期失败模式改变:subprocess seam 损坏现在让首次搜索调用失败(`SEARCH_FAILED`),而非通过探针使插件加载失败;二进制缺失是带打包路径的启动失败,而不是 PATH 问题。 - 集成套件的 fixture 去掉了 Windows 无法表示的文件名(名称含 `"`),保证套件在每个平台都能重放。 +- 重新生成 `THIRD_PARTY_NOTICES.md` 暴露了一个由新依赖带出的潜在生成器 bug:Node 的 `fs.globSync` 返回操作系统原生分隔符,因此在 Windows 上 notices 分层中带 `/` 后缀的 dev 区前缀永远匹配不上,dev-only 包(测试工具、support 叶子)被错分为 runtime。生成器现在在入口处归一化清单路径,notices 与平台无关。 +- `@vscode/ripgrep` 依赖为 runtime 层增加其 MIT 行;pnpm 11 截断的虚拟存储目录名需要在 notices 生成器的元数据查找中增加内容扫描回退。 From a9871d4af1760d7564c934185fac19f426c3aae5 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 1 Aug 2026 21:56:16 +0800 Subject: [PATCH 23/52] refactor(fs-search): apply #1119 review fixes to the packaged-rg spawn - delete the singleQuote shell-quoting helper and its bash-spawning tests (no in-repo consumers; no shell layer exists anymore) - drop spill from both collect streams: the tool never reads a raw spill path, and a lossy stdout read is a pure SEARCH_RAW_OUTPUT_OVERFLOW error - prepend --no-config so a host RIPGREP_CONFIG_PATH cannot inject a --pre preprocessor into the unconfined spawn - promote graceMs and stderrMaxBytes to validated Config fields (defaults SEARCH_GRACE_MS / SEARCH_STDERR_MAX_BYTES) instead of inheriting bash-local's config - correct the grep tool's JSDoc seam reference (bash -> subprocess) - drop the dead exit-127/command-not-found classification branch --- ...26-08-01-packaged-ripgrep-search.i18n.yaml | 4 +- .../2026-08-01-packaged-ripgrep-search.md | 4 +- .../2026-08-01-packaged-ripgrep-search.zh.md | 4 +- docs/config-catalog.md | 6 +- packages/fs/tool-fs-search/README.i18n.yaml | 4 +- packages/fs/tool-fs-search/README.md | 6 +- packages/fs/tool-fs-search/README.zh.md | 6 +- packages/fs/tool-fs-search/src/glob.ts | 6 +- packages/fs/tool-fs-search/src/grep.ts | 8 ++- packages/fs/tool-fs-search/src/index.ts | 17 +++++- packages/fs/tool-fs-search/src/search-core.ts | 40 ++++++++----- packages/fs/tool-fs-search/src/shell-quote.ts | 24 -------- .../tool-fs-search/tests/shell-quote.spec.ts | 59 ------------------- .../fs/tool-fs-search/tests/tools.spec.ts | 44 +++++++------- 14 files changed, 97 insertions(+), 135 deletions(-) delete mode 100644 packages/fs/tool-fs-search/src/shell-quote.ts delete mode 100644 packages/fs/tool-fs-search/tests/shell-quote.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml index df6948209a..3647587fa5 100644 --- a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md -2026-08-01-packaged-ripgrep-search.md: 0470623163ff7686bdd77ca715bac68c9a466d2b -2026-08-01-packaged-ripgrep-search.zh.md: fe53643aff139615d49edd2e3b27d14428cd2029 +2026-08-01-packaged-ripgrep-search.md: 849cc0804a2081492297649ac8f13236e2ac60fe +2026-08-01-packaged-ripgrep-search.zh.md: 4381c3a8bca8e6bb9ab375ad352fec5a5ddb9f99 diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md index 0470623163..849cc0804a 100644 --- a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md @@ -12,7 +12,7 @@ The `glob`/`grep` tools ran through the bash executor seam, which made a system ## Decision -`@deepseek-ai/dsh-tool-fs-search` now runs the PACKAGED ripgrep binary (`@vscode/ripgrep`, an npm dependency whose optional platform packages ship the binary) through the `ctx.subprocess` seam: `runRipgrep()` spawns `rgPath` with a plain argv vector, collect-mode stdout/stderr, `graceMs`, and `exec.signal` forwarded. There is no shell layer, so the shell-quoting boundary is gone from execution; `singleQuote` stays exported as a compatibility surface with its tests. Registration is unconditional — the load-time `command -v rg` probe and the conditional registration decision are deleted, and with them the "rg not found" warning. The package injects `tools`, `systemPrompt`, and `subprocess`. +`@deepseek-ai/dsh-tool-fs-search` now runs the PACKAGED ripgrep binary (`@vscode/ripgrep`, an npm dependency whose optional platform packages ship the binary) through the `ctx.subprocess` seam: `runRipgrep()` spawns `rgPath` with a plain argv vector prefixed by `--no-config`, collect-mode stdout/stderr, `graceMs`, and `exec.signal` forwarded. There is no shell layer, so the shell-quoting boundary is gone from execution; the `singleQuote` helper and its shell-spawning tests are deleted with it. The raw streams request the seam's diagnostic-tail collect shape (no spill files — the tool never reads a raw spill path; a lossy stdout read fails as `SEARCH_RAW_OUTPUT_OVERFLOW`). The terminate grace and the stderr tail budget are validated `Config` fields (`graceMs` default 3000, `stderrMaxBytes` default 64 KiB), no longer inherited from bash-local's config. Registration is unconditional — the load-time `command -v rg` probe and the conditional registration decision are deleted, and with them the "rg not found" warning. The package injects `tools`, `systemPrompt`, and `subprocess`. Exit semantics stay tool-owned: exit 0 is success with results, exit 1 is a successful empty search, anything else classifies into the existing `SEARCH_*` vocabulary (invalid pattern, launch failure, signal kill, raw-output overflow). Timeout is the cooperative tool-call budget attached to the tool definitions: `@deepseek-ai/dsh-timeout-policy` aborts `exec.signal`, the subprocess seam's terminate escalation provides the hard kill, and the tool reports `SEARCH_ABORTED`. The working directory is the session header cwd when present, else `process.cwd()` — there is no executor config to default through anymore, so the tool owns the fallback. @@ -30,6 +30,8 @@ The `fs-glob-sampling` ACP snapshot scenario now executes the real packaged bina - The discovery tools work on every platform the packaged binary covers (darwin/linux/win32, x64/arm64) with no host install; the shipped TUI/Web rosters gain `glob`/`grep` as fixed members ([even-out-shipped-tool-rosters](../feature/2026-07-31-even-out-shipped-tool-rosters.md)). - The shell-string attack surface is gone: hostile patterns are inert argv elements, pinned by the integration suite, which now runs on Windows too (it previously self-skipped without a system `rg`). +- The spawn is unconfined (a plain `ctx.subprocess` call), so `--no-config` is prepended: a host `RIPGREP_CONFIG_PATH` (or an `rg.conf` beside the binary) can otherwise inject a `--pre` preprocessor that executes an arbitrary command for every matched file. With `--no-config`, no config file — and therefore no preprocessor — can reach the search. +- The raw-output overflow path changed shape: the old bash-backed route inherited bash-local's always-on spill and could leave an unread multi-megabyte temp file; the subprocess seam now collects without spill, and overflow is a pure error (`SEARCH_RAW_OUTPUT_OVERFLOW`, "narrow pattern, path, or include and retry") with zero content returned. - Load-time failure modes changed: a broken subprocess seam now fails the first search call (`SEARCH_FAILED`) instead of failing plugin load through the probe; a missing binary is a launch failure with the packaged path, not a PATH problem. - The integration suite's fixture dropped a filename Windows cannot represent (`"` in a name), keeping the suite replayable on every platform. - Regenerating `THIRD_PARTY_NOTICES.md` surfaced a latent generator bug the new dependency made visible: Node's `fs.globSync` returns OS-native separators, so on Windows the `/`-suffixed dev-area prefixes in the notices tiering never matched and dev-only packages (test tooling, support leaves) were mis-tiered as runtime. The generator now normalizes manifest paths at ingestion, and the notices are platform-independent. diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md index fe53643aff..4381c3a8bc 100644 --- a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -`@deepseek-ai/dsh-tool-fs-search` 现在运行 PACKAGED(打包的)ripgrep 二进制(`@vscode/ripgrep`,一个 npm 依赖,其可选平台包随附二进制),经由 `ctx.subprocess` seam:`runRipgrep()` 以纯 argv 向量 spawn `rgPath`,配以 collect 模式 stdout/stderr、`graceMs` 与转发的 `exec.signal`。不再有 shell 层,执行路径上的 shell 引号边界随之消失;`singleQuote` 作为兼容导出与其测试保留。注册变为无条件——加载期 `command -v rg` 探针与条件注册决策被删除,连同那条 "rg not found" 警告。本包注入 `tools`、`systemPrompt` 与 `subprocess`。 +`@deepseek-ai/dsh-tool-fs-search` 现在运行 PACKAGED(打包的)ripgrep 二进制(`@vscode/ripgrep`,一个 npm 依赖,其可选平台包随附二进制),经由 `ctx.subprocess` seam:`runRipgrep()` 以纯 argv 向量 spawn `rgPath`,向量前缀 `--no-config`,配以 collect 模式 stdout/stderr、`graceMs` 与转发的 `exec.signal`。不再有 shell 层,执行路径上的 shell 引号边界随之消失;`singleQuote` 工具与其 shell spawn 测试一并删除。原始流使用 seam 的诊断尾部 collect 形态(无 spill 文件——工具从不读取原始 spill 路径;lossy stdout 读取以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败)。终止宽限与 stderr 尾部预算成为经校验的 `Config` 字段(`graceMs` 默认 3000,`stderrMaxBytes` 默认 64 KiB),不再继承自 bash-local 的配置。注册变为无条件——加载期 `command -v rg` 探针与条件注册决策被删除,连同那条 "rg not found" 警告。本包注入 `tools`、`systemPrompt` 与 `subprocess`。 退出语义仍由工具拥有:退出码 0 为有结果的成功,1 为成功的空搜索,其余归入既有 `SEARCH_*` 词汇(无效模式、启动失败、信号杀死、原始输出溢出)。超时是挂在工具定义上的协作式工具调用预算:`@deepseek-ai/dsh-timeout-policy` 中止 `exec.signal`,subprocess seam 的终止升级提供硬终止,工具报告 `SEARCH_ABORTED`。工作目录为会话 header cwd(存在时),否则为 `process.cwd()`——不再有执行器配置可供默认化,因此回退由工具自己拥有。 @@ -30,6 +30,8 @@ Status: implemented - 发现工具在打包二进制覆盖的每个平台(darwin/linux/win32,x64/arm64)上开箱即用,无需宿主安装;交付的 TUI/Web 工具清单把 `glob`/`grep` 变为固定成员(见 [拉平交付的工具清单](../feature/2026-07-31-even-out-shipped-tool-rosters.md))。 - shell 字符串攻击面消失:恶意模式只是惰性 argv 元素,由集成套件钉住;该套件现在也在 Windows 上运行(此前没有系统 `rg` 时它自行跳过)。 +- spawn 不受沙箱约束(普通的 `ctx.subprocess` 调用),因此前缀 `--no-config`:宿主的 `RIPGREP_CONFIG_PATH`(或二进制旁的 `rg.conf`)否则可注入 `--pre` 预处理器,对每个匹配文件执行任意命令。加上 `--no-config` 后,任何配置文件——因而任何预处理器——都无法触及搜索。 +- 原始输出溢出路径的形态改变:旧的 bash 承载路径继承了 bash-local 常开的 spill,可能留下没人读的多 MB 临时文件;subprocess seam 现在无 spill 收集,溢出是纯粹的错误(`SEARCH_RAW_OUTPUT_OVERFLOW`,"narrow pattern, path, or include and retry"),不返回任何内容。 - 加载期失败模式改变:subprocess seam 损坏现在让首次搜索调用失败(`SEARCH_FAILED`),而非通过探针使插件加载失败;二进制缺失是带打包路径的启动失败,而不是 PATH 问题。 - 集成套件的 fixture 去掉了 Windows 无法表示的文件名(名称含 `"`),保证套件在每个平台都能重放。 - 重新生成 `THIRD_PARTY_NOTICES.md` 暴露了一个由新依赖带出的潜在生成器 bug:Node 的 `fs.globSync` 返回操作系统原生分隔符,因此在 Windows 上 notices 分层中带 `/` 后缀的 dev 区前缀永远匹配不上,dev-only 包(测试工具、support 叶子)被错分为 runtime。生成器现在在入口处归一化清单路径,notices 与平台无关。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 20f835bb7f..1151f0ff0d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1733,12 +1733,16 @@ export interface Config { searchMetaMaxBytes?: number /** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */ rawOutputMaxBytes?: number + /** Terminate-escalation grace period (ms) for one search process, handed to the subprocess seam. */ + graceMs?: number + /** Max bytes retained for one search's stderr diagnostic tail (never surfaced to the model). */ + stderrMaxBytes?: number /** Cooperative tool-call timeout budget (ms) on both tools, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`. */ timeoutMs?: number } ``` -Source: [`packages/fs/tool-fs-search/src/index.ts:70`](../packages/fs/tool-fs-search/src/index.ts) +Source: [`packages/fs/tool-fs-search/src/index.ts:71`](../packages/fs/tool-fs-search/src/index.ts) ## `@deepseek-ai/dsh-tool-goal` diff --git a/packages/fs/tool-fs-search/README.i18n.yaml b/packages/fs/tool-fs-search/README.i18n.yaml index 5ed7bfb8bb..a8e2222998 100644 --- a/packages/fs/tool-fs-search/README.i18n.yaml +++ b/packages/fs/tool-fs-search/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/fs/tool-fs-search/README.md -README.md: 0152be017ae15fc83a3d5cb7df927f25d04d2d53 -README.zh.md: 69ce49f3ad1621021dc1d0938cdc07a900d1cda8 +README.md: 78ffa069e56da5fc987913acf761eb5c6ae15b1a +README.zh.md: 42b123d5c47d8f48bc21b6f9bed4905372ca8625 diff --git a/packages/fs/tool-fs-search/README.md b/packages/fs/tool-fs-search/README.md index 0152be017a..78ffa069e5 100644 --- a/packages/fs/tool-fs-search/README.md +++ b/packages/fs/tool-fs-search/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **model-facing filesystem discovery tools**—`glob`, `grep`—are backed by the **packaged ripgrep binary** (`@vscode/ripgrep`), not by `ctx.fs` provider methods and not by a system `rg` install. Registration is unconditional: the binary ships inside the npm dependency, so there is no load-time availability probe. Each call spawns the binary through the `ctx.subprocess` seam with a fixed argv vector (model-controlled values are plain argv elements — no shell layer exists, so no quoting applies), parses the raw `rg` output, and returns a workdir-relative canonical value. The package injects `tools`, `systemPrompt`, and `subprocess`—deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional. +The **model-facing filesystem discovery tools**—`glob`, `grep`—are backed by the **packaged ripgrep binary** (`@vscode/ripgrep`), not by `ctx.fs` provider methods and not by a system `rg` install. Registration is unconditional: the binary ships inside the npm dependency, so there is no load-time availability probe. Each call spawns the binary through the `ctx.subprocess` seam with a fixed argv vector (`--no-config` prepended so a host `RIPGREP_CONFIG_PATH` cannot inject a `--pre` preprocessor into the unconfined spawn; model-controlled values are plain argv elements — no shell layer exists, so no quoting applies), parses the raw `rg` output, and returns a workdir-relative canonical value. The package injects `tools`, `systemPrompt`, and `subprocess`—deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional. ```ts ignore-check // A deployment chooses how over-cap glob pages are selected. @@ -30,6 +30,8 @@ The binary ships with the package on every supported platform (macOS/Linux/Windo | `grepMaxLineBytes` | `2000` | Byte cap per matched-line preview; the cut preserves UTF-8 boundaries and is marked `(line truncated)`. | | `rawOutputMaxBytes` | `20000000` | Max complete raw `rg` stdout a search will parse (matches Claude Code's ripgrep raw buffer); larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. | | `timeoutMs` | `30000` | Cooperative tool-call budget attached to both tool definitions, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`; the subprocess seam's terminate escalation is the hard kill. | +| `graceMs` | `3000` | Terminate-escalation grace period the subprocess seam grants past `timeoutMs` before the search fails as `SEARCH_ABORTED`. | +| `stderrMaxBytes` | `65536` | Diagnostic-tail budget for `rg` stderr, captured through the subprocess seam's collect disposition; a lossy read keeps only the tail (marked `[stderr truncated]`). | ## Tools @@ -42,7 +44,7 @@ Routine budgets stay out of the model-facing schema (no `head_limit`/`offset`/`c ## Two budgets, two artifacts -Raw `rg` stdout is an internal transport detail. Each search requests a collect-mode stdout budget of `rawOutputMaxBytes` from the subprocess seam and parses only complete retained stdout; if the seam still reports a lossy read, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. A successful `glob` keeps the displayed search root and every acquired path in `{ root, paths }`; when sampling is enabled, `root` lets the Native renderer group an explicit relative or absolute search path by entries beneath that root rather than by its workdir prefix. `grep` keeps every acquired `{ path, lineNumber, line }` in `{ matches }`. Inline item and per-line preview caps apply only in the Native renderer. For a direct surface call with more logical results than the inline cap, post-policy best-effort saves the complete formatted preview through `ctx.spillStore.saveText()` and replaces only presentation with the configured page plus locator. Nested Code dispatches skip that spill because their full canonical value does not enter model context. Missing/failed spill keeps the inline page and reports that the complete result could not be saved—never an `isError`. +Raw `rg` stdout and stderr are internal transport details. Each search requests collect-mode budgets from the subprocess seam — complete stdout within `rawOutputMaxBytes` and a `stderrMaxBytes` diagnostic tail — with no spill files on either stream (the tool never reads a raw spill path). If the seam still reports a lossy stdout read, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query; a lossy stderr read only marks the diagnostic excerpt `[stderr truncated]`. A successful `glob` keeps the displayed search root and every acquired path in `{ root, paths }`; when sampling is enabled, `root` lets the Native renderer group an explicit relative or absolute search path by entries beneath that root rather than by its workdir prefix. `grep` keeps every acquired `{ path, lineNumber, line }` in `{ matches }`. Inline item and per-line preview caps apply only in the Native renderer. For a direct surface call with more logical results than the inline cap, post-policy best-effort saves the complete formatted preview through `ctx.spillStore.saveText()` and replaces only presentation with the configured page plus locator. Nested Code dispatches skip that spill because their full canonical value does not enter model context. Missing/failed spill keeps the inline page and reports that the complete result could not be saved—never an `isError`. ## Errors diff --git a/packages/fs/tool-fs-search/README.zh.md b/packages/fs/tool-fs-search/README.zh.md index 69ce49f3ad..42b123d5c4 100644 --- a/packages/fs/tool-fs-search/README.zh.md +++ b/packages/fs/tool-fs-search/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -**面向模型的文件系统发现工具**(`glob`、`grep`)由 **打包的 ripgrep 二进制**(`@vscode/ripgrep`)支持,而不是由 `ctx.fs` 提供方方法或系统 `rg` 安装支持。注册是无条件的:二进制随 npm 依赖一起交付,因此没有加载期可用性探针。每次调用都通过 `ctx.subprocess` seam 以固定 argv 向量 spawn 该二进制(模型控制的值是普通 argv 元素——不存在 shell 层,因此无需引号),解析原始 `rg` 输出,并返回相对于工作目录的规范值。本包注入 `tools`、`systemPrompt` 和 `subprocess`,有意**不**注入 `fs`;格式化结果 spill 为可选功能,因此机会性读取 `ctx.spillStore`,调用方式为 `ctx.get()`。 +**面向模型的文件系统发现工具**(`glob`、`grep`)由 **打包的 ripgrep 二进制**(`@vscode/ripgrep`)支持,而不是由 `ctx.fs` 提供方方法或系统 `rg` 安装支持。注册是无条件的:二进制随 npm 依赖一起交付,因此没有加载期可用性探针。每次调用都通过 `ctx.subprocess` seam 以固定 argv 向量 spawn 该二进制(前缀 `--no-config`,使宿主的 `RIPGREP_CONFIG_PATH` 无法向不受约束的 spawn 注入 `--pre` 预处理器;模型控制的值是普通 argv 元素——不存在 shell 层,因此无需引号),解析原始 `rg` 输出,并返回相对于工作目录的规范值。本包注入 `tools`、`systemPrompt` 和 `subprocess`,有意**不**注入 `fs`;格式化结果 spill 为可选功能,因此机会性读取 `ctx.spillStore`,调用方式为 `ctx.get()`。 ```ts ignore-check // A deployment chooses how over-cap glob pages are selected. @@ -30,6 +30,8 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh- | `grepMaxLineBytes` | `2000` | 每条匹配行预览的字节上限;截断会保留 UTF-8 边界,并标记为 `(line truncated)`。 | | `rawOutputMaxBytes` | `20000000` | 搜索将解析的完整原始 `rg` stdout 上限(与 Claude Code 的 ripgrep 原始 buffer 相同);更大的原始输出以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败。 | | `timeoutMs` | `30000` | 附加到两个工具定义上的协作式工具调用预算,由 `@deepseek-ai/dsh-timeout-policy` 通过 `exec.signal` 强制执行;subprocess seam 的终止升级提供硬终止。 | +| `graceMs` | `3000` | subprocess seam 在 `timeoutMs` 之外授予的终止升级宽限期;超过后搜索以 `SEARCH_ABORTED` 失败。 | +| `stderrMaxBytes` | `65536` | `rg` stderr 的诊断尾部预算,经 subprocess seam 的 collect 形态捕获;lossy 读取只保留尾部(标记 `[stderr truncated]`)。 | ## 工具 @@ -42,7 +44,7 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh- ## 两类预算、两类产物 -原始 `rg` stdout 是内部传输细节。每次搜索从 subprocess seam 请求 `rawOutputMaxBytes` 的 collect 模式 stdout 预算,且只解析完整保留的 stdout;如果 seam 仍报告 lossy 读取,搜索会以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败,并要求模型缩小查询。成功的 `glob` 在 `{ root, paths }` 中保留所显示的搜索根及所有已取得路径;启用采样时,借助 `root`,原生渲染器能以显式的相对或绝对搜索路径为根,按该根下的条目分组,而不是按其工作目录前缀分组。`grep` 保留所有已取得的 `{ path, lineNumber, line }`,并将其存入 `{ matches }`。内联条目和每行预览上限只应用于原生渲染器。直接接口调用的逻辑结果超过内联上限时,后置策略会尽力通过 `ctx.spillStore.saveText()` 保存完整格式化预览,并只把呈现替换为配置指定的页面与 locator。嵌套 Code 分派会跳过 spill,因为其完整规范值不会进入模型上下文。spill 缺失/失败时保留内联页面,并报告完整结果无法保存,绝不会成为 `isError`。 +原始 `rg` stdout 与 stderr 是内部传输细节。每次搜索从 subprocess seam 请求 collect 模式预算——`rawOutputMaxBytes` 内的完整 stdout 与 `stderrMaxBytes` 的诊断尾部——两条流都不产生 spill 文件(工具从不读取原始 spill 路径)。如果 seam 仍报告 lossy stdout 读取,搜索会以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败,并要求模型缩小查询;lossy stderr 读取只把诊断摘录标记为 `[stderr truncated]`。成功的 `glob` 在 `{ root, paths }` 中保留所显示的搜索根及所有已取得路径;启用采样时,借助 `root`,原生渲染器能以显式的相对或绝对搜索路径为根,按该根下的条目分组,而不是按其工作目录前缀分组。`grep` 保留所有已取得的 `{ path, lineNumber, line }`,并将其存入 `{ matches }`。内联条目和每行预览上限只应用于原生渲染器。直接接口调用的逻辑结果超过内联上限时,后置策略会尽力通过 `ctx.spillStore.saveText()` 保存完整格式化预览,并只把呈现替换为配置指定的页面与 locator。嵌套 Code 分派会跳过 spill,因为其完整规范值不会进入模型上下文。spill 缺失/失败时保留内联页面,并报告完整结果无法保存,绝不会成为 `isError`。 ## 错误 diff --git a/packages/fs/tool-fs-search/src/glob.ts b/packages/fs/tool-fs-search/src/glob.ts index 6e7d411a01..3eeea9c75c 100644 --- a/packages/fs/tool-fs-search/src/glob.ts +++ b/packages/fs/tool-fs-search/src/glob.ts @@ -47,6 +47,10 @@ export interface GlobToolCaps { maxMetaBytes: number /** Cap on the complete raw `rg` stdout the tool will parse. */ rawOutputMaxBytes: number + /** Terminate-escalation grace period (ms) for the search process. */ + graceMs: number + /** Cap on the retained stderr diagnostic tail. */ + stderrMaxBytes: number /** Cooperative tool-call budget (ms) attached as `ToolDefinition.timeoutMs`. */ timeoutMs: number } @@ -337,7 +341,7 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void { }, async execute(args, exec) { const input = parseGlobArgs(args) - const run = await runRipgrep(ctx, exec, 'glob', buildGlobCommand(input), caps.rawOutputMaxBytes) + const run = await runRipgrep(ctx, exec, 'glob', buildGlobCommand(input), caps.rawOutputMaxBytes, caps.graceMs, caps.stderrMaxBytes) const root = input.path === undefined ? '.' : toWorkdirRelative(input.path, run.workdir) if (run.noMatches) return { root, paths: [] } diff --git a/packages/fs/tool-fs-search/src/grep.ts b/packages/fs/tool-fs-search/src/grep.ts index 03b49f01e8..49548499ac 100644 --- a/packages/fs/tool-fs-search/src/grep.ts +++ b/packages/fs/tool-fs-search/src/grep.ts @@ -45,6 +45,10 @@ export interface GrepToolCaps { maxMetaBytes: number /** Cap on the complete raw `rg` stdout the tool will parse. */ rawOutputMaxBytes: number + /** Terminate-escalation grace period (ms) for the search process. */ + graceMs: number + /** Cap on the retained stderr diagnostic tail. */ + stderrMaxBytes: number /** Cooperative tool-call budget (ms) attached as `ToolDefinition.timeoutMs`. */ timeoutMs: number } @@ -265,7 +269,7 @@ export function presentGrepResult( * Register the `grep` tool and its system-prompt guidance. * * @param ctx - the plugin context; registrations are effects scoped to it, and - * execution uses its `bash` service. + * execution uses its `subprocess` service. * @param caps - the deployment's resolved grep caps (plugin config after defaulting). */ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void { @@ -315,7 +319,7 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void { }, async execute(args, exec) { const input = parseGrepArgs(args) - const run = await runRipgrep(ctx, exec, 'grep', buildGrepCommand(input), caps.rawOutputMaxBytes) + const run = await runRipgrep(ctx, exec, 'grep', buildGrepCommand(input), caps.rawOutputMaxBytes, caps.graceMs, caps.stderrMaxBytes) if (run.noMatches) return { matches: [] } const all: GrepMatch[] = [] diff --git a/packages/fs/tool-fs-search/src/index.ts b/packages/fs/tool-fs-search/src/index.ts index e596ae3ca1..9a4042dde1 100644 --- a/packages/fs/tool-fs-search/src/index.ts +++ b/packages/fs/tool-fs-search/src/index.ts @@ -30,7 +30,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import { GLOB_MAX_RESULTS, applyGlobTool } from './glob.ts' import { GREP_MAX_LINE_BYTES, GREP_MAX_MATCHES, applyGrepTool } from './grep.ts' -import { RAW_OUTPUT_MAX_BYTES, SEARCH_META_MAX_BYTES, SEARCH_TIMEOUT_MS } from './search-core.ts' +import { RAW_OUTPUT_MAX_BYTES, SEARCH_GRACE_MS, SEARCH_META_MAX_BYTES, SEARCH_STDERR_MAX_BYTES, SEARCH_TIMEOUT_MS } from './search-core.ts' export { GLOB_MAX_RESULTS, GLOB_VCS_EXCLUDES, applyGlobTool, buildGlobCommand, formatGlobOutput, parseGlobArgs, presentGlobCall, presentGlobResult, sampleAcrossTopLevel } from './glob.ts' export type { GlobInput, GlobSample, GlobToolCaps } from './glob.ts' @@ -49,7 +49,9 @@ export { export type { GrepInput, GrepToolCaps } from './grep.ts' export { RAW_OUTPUT_MAX_BYTES, + SEARCH_GRACE_MS, SEARCH_META_MAX_BYTES, + SEARCH_STDERR_MAX_BYTES, SEARCH_TIMEOUT_MS, SearchError, previewLine, @@ -58,7 +60,6 @@ export { trySaveFormattedResult, } from './search-core.ts' export type { GrepMatch, RipgrepRun, SearchErrorCode } from './search-core.ts' -export { singleQuote } from './shell-quote.ts' /** Cordis plugin name used by loader diagnostics. */ export const name = 'tool-fs-search' @@ -80,6 +81,10 @@ export interface Config { searchMetaMaxBytes?: number /** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */ rawOutputMaxBytes?: number + /** Terminate-escalation grace period (ms) for one search process, handed to the subprocess seam. */ + graceMs?: number + /** Max bytes retained for one search's stderr diagnostic tail (never surfaced to the model). */ + stderrMaxBytes?: number /** Cooperative tool-call timeout budget (ms) on both tools, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`. */ timeoutMs?: number } @@ -91,6 +96,8 @@ export const Config: z = z.object({ grepMaxLineBytes: z.number().default(GREP_MAX_LINE_BYTES), searchMetaMaxBytes: z.number().default(SEARCH_META_MAX_BYTES), rawOutputMaxBytes: z.number().default(RAW_OUTPUT_MAX_BYTES), + graceMs: z.number().default(SEARCH_GRACE_MS), + stderrMaxBytes: z.number().default(SEARCH_STDERR_MAX_BYTES), timeoutMs: z.number().default(SEARCH_TIMEOUT_MS), }) @@ -121,12 +128,16 @@ export async function apply(ctx: Context, config: Config): Promise { assertPositiveInteger('grepMaxLineBytes', resolved.grepMaxLineBytes) assertPositiveInteger('searchMetaMaxBytes', resolved.searchMetaMaxBytes) assertPositiveInteger('rawOutputMaxBytes', resolved.rawOutputMaxBytes) + assertPositiveInteger('graceMs', resolved.graceMs) + assertPositiveInteger('stderrMaxBytes', resolved.stderrMaxBytes) assertPositiveInteger('timeoutMs', resolved.timeoutMs) applyGlobTool(ctx, { sampleOverCapGlobResults: resolved.sampleOverCapGlobResults, maxResults: resolved.globMaxResults, maxMetaBytes: resolved.searchMetaMaxBytes, rawOutputMaxBytes: resolved.rawOutputMaxBytes, + graceMs: resolved.graceMs, + stderrMaxBytes: resolved.stderrMaxBytes, timeoutMs: resolved.timeoutMs, }) applyGrepTool(ctx, { @@ -134,6 +145,8 @@ export async function apply(ctx: Context, config: Config): Promise { maxLineBytes: resolved.grepMaxLineBytes, maxMetaBytes: resolved.searchMetaMaxBytes, rawOutputMaxBytes: resolved.rawOutputMaxBytes, + graceMs: resolved.graceMs, + stderrMaxBytes: resolved.stderrMaxBytes, timeoutMs: resolved.timeoutMs, }) } diff --git a/packages/fs/tool-fs-search/src/search-core.ts b/packages/fs/tool-fs-search/src/search-core.ts index c9f5f80b5b..9444b029cc 100644 --- a/packages/fs/tool-fs-search/src/search-core.ts +++ b/packages/fs/tool-fs-search/src/search-core.ts @@ -44,15 +44,13 @@ export const SEARCH_TIMEOUT_MS = 30_000 /** * Default cap in bytes on the retained stderr tail of one search run — a - * diagnostic excerpt only (the tool never reads `stderr.spillPath`). + * diagnostic excerpt only (the tool never reads a stderr spill path, and the + * collect disposition requests none). */ -const SEARCH_STDERR_MAX_BYTES = 64 * 1024 - -/** Default whole-stream spill cap for search output (the subprocess seam requires an explicit budget). */ -const SEARCH_SPILL_MAX_BYTES = 64 * 1024 * 1024 +export const SEARCH_STDERR_MAX_BYTES = 64 * 1024 /** Default terminate grace period for a search process (ms). */ -const SEARCH_GRACE_MS = 3_000 +export const SEARCH_GRACE_MS = 3_000 /** * Default cap in bytes on one search's serialized `presentationMeta` (the @@ -110,7 +108,7 @@ export interface RipgrepRun { /** * The retained stderr tail as a diagnostic excerpt, with a truncation note when - * the subprocess seam dropped bytes (the tool never reads `stderr.spillPath`). + * the subprocess seam dropped bytes. */ function stderrExcerpt(stderrText: string, truncated: boolean): string { const text = stderrText.trim() @@ -118,15 +116,16 @@ function stderrExcerpt(stderrText: string, truncated: boolean): string { return truncated ? `${text} [stderr truncated]` : text } -/** Classify a nonzero-exit `rg` run into the search error vocabulary (invalid pattern vs missing `rg` vs everything else). */ +/** + * Classify a nonzero-exit `rg` run into the search error vocabulary. There is + * no shell layer, so an exit 127 or shell "command not found" text cannot + * occur — a launch failure rejects at spawn (see {@link runRipgrep}). + */ function classifyRunFailure(toolName: string, exitCode: number, stderrText: string, stderrTruncated: boolean): SearchError { const stderr = stderrExcerpt(stderrText, stderrTruncated) if (/regex parse error|error parsing glob/i.test(stderr)) { return new SearchError(`${toolName} pattern rejected by ripgrep: ${stderr}`, 'SEARCH_INVALID_PATTERN') } - if (exitCode === 127 || /command not found/i.test(stderr)) { - return new SearchError(`${toolName} requires ripgrep (rg) to launch${stderr.length > 0 ? `: ${stderr}` : ''}`, 'SEARCH_FAILED') - } return new SearchError(`${toolName} search failed (exit ${exitCode})${stderr.length > 0 ? `: ${stderr}` : ''}`, 'SEARCH_FAILED') } @@ -163,6 +162,13 @@ function completeStdout(toolName: string, stdout: SubprocessOutputRead, rawOutpu * (`@deepseek-ai/dsh-timeout-policy`) and caller cancellation terminate the * process tree. * + * The spawn is unconfined (a plain `ctx.subprocess` call), so `--no-config` + * is prepended: a host `RIPGREP_CONFIG_PATH` (or `rg.conf` next to the + * binary) can otherwise inject `--pre` and make ripgrep execute an arbitrary + * preprocessor for every matched file. The collect dispositions are the + * seam's diagnostic-tail shape (no spill files): the tools never read a raw + * spill path, and truncated stdout fails as `SEARCH_RAW_OUTPUT_OVERFLOW`. + * * Exit semantics are tool-owned: exit 0 is success with results, exit 1 is * success with zero results (`noMatches`), anything else throws a * {@link SearchError} (abort/timeout → `SEARCH_ABORTED`, invalid pattern → @@ -176,6 +182,8 @@ function completeStdout(toolName: string, stdout: SubprocessOutputRead, rawOutpu * @param toolName - `glob` or `grep`, used in error messages. * @param argv - the ripgrep arguments (every model value an unquoted argv element; no shell layer exists). * @param rawOutputMaxBytes - cap on the complete raw stdout the tool will parse. + * @param graceMs - the seam's terminate-escalation grace period. + * @param stderrMaxBytes - cap on the retained stderr diagnostic tail. * @returns the complete stdout, the zero-result flag, and the resolved workdir. */ export async function runRipgrep( @@ -184,6 +192,8 @@ export async function runRipgrep( toolName: string, argv: readonly string[], rawOutputMaxBytes: number, + graceMs: number, + stderrMaxBytes: number, ): Promise { if (exec.signal.aborted) { throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED') @@ -191,16 +201,16 @@ export async function runRipgrep( const cwd = exec.agent?.session.header.cwd const workdir = cwd ?? process.cwd() const collect = (maxBytes: number): SubprocessCollect => - ({ maxBytes, spill: { maxBytes: SEARCH_SPILL_MAX_BYTES } }) + ({ maxBytes }) const handle = ctx.subprocess.spawn({ - argv: [rgPath, ...argv], + argv: [rgPath, '--no-config', ...argv], cwd: workdir, stdio: { stdin: 'ignore', stdout: collect(rawOutputMaxBytes), - stderr: collect(SEARCH_STDERR_MAX_BYTES), + stderr: collect(stderrMaxBytes), }, - graceMs: SEARCH_GRACE_MS, + graceMs, signal: exec.signal, } satisfies SubprocessSpawnSpec) let outcome: SubprocessOutcome diff --git a/packages/fs/tool-fs-search/src/shell-quote.ts b/packages/fs/tool-fs-search/src/shell-quote.ts deleted file mode 100644 index ea67abf449..0000000000 --- a/packages/fs/tool-fs-search/src/shell-quote.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * POSIX single-quoting helper retained for compatibility with older - * deployments and tests. The current `glob`/`grep` command builders spawn the - * packaged ripgrep binary with a plain argv vector — no shell layer exists — - * so no quoting is involved; this module is kept because its export is part - * of the package surface. - * - * @module @deepseek-ai/dsh-tool-fs-search/shell-quote - */ - -/** - * POSIX single-quote a string for safe use as ONE shell word. Wraps the value - * in single quotes and rewrites every embedded single quote as `'\''` (close - * quote, an escaped literal quote, reopen quote). Inside single quotes the shell - * treats every other byte literally — spaces, newlines, `$`, backticks, `;`, - * `|`, `&`, glob metacharacters, and a leading `-` are all inert — so the result - * is a single, injection-safe argument regardless of the input. - * - * @param value - the raw, possibly model-controlled string to quote. - * @returns the value wrapped as one safe single-quoted shell word. - */ -export function singleQuote(value: string): string { - return `'${value.replaceAll("'", "'\\''")}'` -} diff --git a/packages/fs/tool-fs-search/tests/shell-quote.spec.ts b/packages/fs/tool-fs-search/tests/shell-quote.spec.ts deleted file mode 100644 index 84c8506be1..0000000000 --- a/packages/fs/tool-fs-search/tests/shell-quote.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Unit tests for the shell-quoting safety boundary, plus a REAL round-trip: - * every adversarial value, quoted, must survive `bash -c "printf '%s' "` - * byte-for-byte — proving the quoting is inert in an actual shell, not just - * against a mental model of one. - */ - -import { describe, expect, it } from 'vitest' -import { spawnSync } from 'node:child_process' -import { singleQuote } from '@deepseek-ai/dsh-tool-fs-search' - -/** Adversarial values a model could pass as pattern / path / include. */ -const HOSTILE: readonly string[] = [ - 'plain', - 'with spaces', - "it's got 'quotes'", - '"double quoted"', - '$(rm -rf /tmp/nope)', - '`touch /tmp/nope`', - '$HOME and ${PATH}', - 'semi;colon && chain || pipe | bg &', - 'newline\nin the middle', - '-leading-dash', - '--leading-double-dash', - '*?[a-z]{x,y}', - '!bang', - '\\backslash\\', - '~tilde', - '# not a comment', - '>redirect &1', -] - -describe('singleQuote', () => { - it('wraps a plain value in single quotes', () => { - expect(singleQuote('abc')).toBe("'abc'") - }) - - it("rewrites embedded single quotes as '\\''", () => { - expect(singleQuote("a'b")).toBe("'a'\\''b'") - expect(singleQuote("''")).toBe("''\\'''\\'''") - }) - - it.each(HOSTILE.map(value => [JSON.stringify(value), value] as const))( - 'round-trips %s through a real bash -c unchanged', - (_label, value) => { - const result = spawnSync('bash', ['-c', `printf '%s' ${singleQuote(value)}`], { encoding: 'utf8' }) - expect(result.status).toBe(0) - expect(result.stdout).toBe(value) - }, - ) - - it('a quoted command substitution does not execute (the world stays untouched)', () => { - const canary = `/tmp/dsh-quote-canary-${process.pid}` - const result = spawnSync('bash', ['-c', `printf '%s' ${singleQuote(`$(touch ${canary})`)}`], { encoding: 'utf8' }) - expect(result.stdout).toBe(`$(touch ${canary})`) - // The canary file must NOT exist — the substitution stayed literal. - expect(spawnSync('test', ['-e', canary]).status).not.toBe(0) - }) -}) diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index d786fa3181..5c6713a7f0 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -291,6 +291,8 @@ describe('config validation', () => { ['grepMaxMatches', { grepMaxMatches: -1 }], ['grepMaxLineBytes', { grepMaxLineBytes: 1.5 }], ['rawOutputMaxBytes', { rawOutputMaxBytes: 0 }], + ['graceMs', { graceMs: 0 }], + ['stderrMaxBytes', { stderrMaxBytes: -1 }], ['timeoutMs', { timeoutMs: -100 }], ] as const)('rejects a non-positive or fractional %s at load', async (name, config) => { const ctx = new Context() @@ -372,16 +374,30 @@ describe('workdir derivation and signal forwarding', () => { expect(subprocess.spawns[1]?.cwd).toBe(process.cwd()) }) - it('spawns the packaged ripgrep binary with the fixed argv and budgeted collect streams', async () => { - const { ctx, subprocess } = await setup({ config: { rawOutputMaxBytes: 1234 } }) + it('spawns the packaged ripgrep binary with --no-config, the fixed argv, and budgeted collect streams', async () => { + const { ctx, subprocess } = await setup({ + config: { rawOutputMaxBytes: 1234, graceMs: 5000, stderrMaxBytes: 4096 }, + }) subprocess.handler = () => runResult('', { exitCode: 1 }) await call(ctx, 'grep', { pattern: 'needle' }) const spec = subprocess.spawns[0] - expect(spec?.argv[0]).toBe(rgPath) - expect(spec?.argv).toEqual([rgPath, '--json', '--regexp=needle']) + // --no-config keeps a host RIPGREP_CONFIG_PATH from injecting a + // preprocessor into this unconfined spawn. + expect(spec?.argv).toEqual([rgPath, '--no-config', '--json', '--regexp=needle']) expect(spec?.stdio.stdin).toBe('ignore') - // stdout gets the tool's parse budget; stderr is a diagnostic excerpt. + // stdout gets the tool's parse budget; stderr is a diagnostic excerpt; + // both are the seam's diagnostic-tail shape (no spill files requested). expect((spec?.stdio.stdout as { maxBytes: number }).maxBytes).toBe(1234) + expect((spec?.stdio.stderr as { maxBytes: number }).maxBytes).toBe(4096) + expect(spec?.graceMs).toBe(5_000) + }) + + it('defaults the stderr tail budget and grace period when the config omits them', async () => { + const { ctx, subprocess } = await setup() + subprocess.handler = () => runResult('', { exitCode: 1 }) + await call(ctx, 'grep', { pattern: 'needle' }) + const spec = subprocess.spawns[0] + expect((spec?.stdio.stderr as { maxBytes: number }).maxBytes).toBe(64 * 1024) expect(spec?.graceMs).toBe(3_000) }) @@ -430,7 +446,7 @@ describe('workdir derivation and signal forwarding', () => { const controller = new AbortController() controller.abort() const exec = { signal: controller.signal, name: 'glob', callId: CallId('direct-pre-abort') } as unknown as ToolExecution - await expect(runRipgrep(ctx, exec, 'glob', ['--files'], 1_000_000)).rejects + await expect(runRipgrep(ctx, exec, 'glob', ['--files'], 1_000_000, 3_000, 64 * 1024)).rejects .toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' }) }) @@ -489,20 +505,6 @@ describe('exit semantics and failure classification', () => { expect(result.error).toMatchObject({ info: { code: 'SEARCH_INVALID_PATTERN' } }) }) - it('a failed ripgrep launch classifies as SEARCH_FAILED naming ripgrep', async () => { - const { ctx, subprocess } = await setup() - subprocess.handler = () => runResult('', { exitCode: 127, stderr: { text: 'sh: rg: command not found' } }) - const result = await call(ctx, 'glob', { pattern: '*' }) - expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } }) - expect(text(result)).toContain('requires ripgrep (rg)') - // The same classification holds from either evidence alone: the 127 exit - // with silent stderr, or a shell's command-not-found text on another exit. - subprocess.handler = () => runResult('', { exitCode: 127 }) - expect(text(await call(ctx, 'glob', { pattern: '*' }))).toContain('requires ripgrep (rg)') - subprocess.handler = () => runResult('', { exitCode: 2, stderr: { text: 'sh: rg: command not found' } }) - expect(text(await call(ctx, 'grep', { pattern: 'x' }))).toContain('requires ripgrep (rg)') - }) - it('other nonzero exits are SEARCH_FAILED carrying the stderr excerpt', async () => { const { ctx, subprocess } = await setup() subprocess.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: missing.dir: IO error: no such file or directory' } }) @@ -682,7 +684,7 @@ describe('glob results', () => { subprocess.handler = () => runResult('sub/a.ts\n') const result = await call(ctx, 'glob', { pattern: '*.ts', path: 'sub' }) expect(result.isError).toBe(false) - expect(subprocess.spawns[0]?.argv).toEqual([rgPath, '--files', '--glob=*.ts', '--sort=modified', '--no-ignore', '--hidden', + expect(subprocess.spawns[0]?.argv).toEqual([rgPath, '--no-config', '--files', '--glob=*.ts', '--sort=modified', '--no-ignore', '--hidden', '--glob=!**/.git', '--glob=!**/.git/**', '--glob=!**/.svn', '--glob=!**/.svn/**', '--glob=!**/.hg', '--glob=!**/.hg/**', From d22438e2b19ead2b14fae5bed5c788d2f9a5f036 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 1 Aug 2026 21:56:32 +0800 Subject: [PATCH 24/52] test(fs-search): re-record the glob-sampling snapshot against the real API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scenario previously carried an authored fixture; W4 of #1119 review requires a live transcript. Recording surfaced two composition bugs that are fixed here alongside it: - provider ids: the app and the replay catalog both named the old 'deepseek' provider, which no adapter registers; both now use 'deepseek-official' - the live config lacked persistenceCompression: none, so record-mode sessions were written zstd-compressed and could not be harvested (the snapshot twin already forced plaintext) Recorded logs also need deterministic replay: - packChunks: false in both configs — the eager-drain batch boundaries that split packed delta runs are timing-dependent, so a packed log of a long reasoning stream cannot replay-match its live record - the fixture's request/header config and request/context are normalized to the replay-produced minimal shape (the live adapter logs model capabilities llm-replay has no data for), and tool-result path separators are canonicalized to '/' for the Linux golden posixOnly is restored now that the fixture is recorded. --- examples/acp-agent/tests/acp.snapshot.ts | 13 +- .../tests/fs-search.cordis.snapshot.yml | 8 +- examples/acp-agent/tests/fs-search.cordis.yml | 7 +- .../snapshots/fs-glob-sampling/session.jsonl | 145 +++++++++++++++--- 4 files changed, 142 insertions(+), 31 deletions(-) diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 277d0b20ca..abdf10e367 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -181,16 +181,23 @@ const SCENARIOS: Scenario[] = [ // `--sort=modified` order, pinning over-cap glob sampling without depending // on a host-installed ripgrep binary or a PATH stand-in. POSIX-only because // the displayed paths carry `/` separators the session-log comparison - // cannot normalize. + // cannot normalize. Recorded (not authored): the assistant turn is a real + // model transcript; re-record with `test:snapshot:record -t fs-glob-sampling`. + // The composition disables packed chunk rows (fs-search.cordis.yml), whose + // run boundaries depend on eager-drain timing, and the recorded fixture's + // `request/header` config and `request/context` are normalized to the + // replay-produced minimal shape (the live adapter logs model capabilities + // like maxTokens/reasoningEffort that llm-replay has no data for), and its + // tool-result paths are canonicalized to `/` separators. { name: 'fs-glob-sampling', hasModelTurn: true, - recorded: false, + recorded: true, + posixOnly: true, pinsHeader: true, headerClass: 'fs-search', configPath: FS_SEARCH_CONFIG, prepareWorkspace: prepareFsSearchWorkspace, - posixOnly: true, }, { name: 'fs-read', hasModelTurn: true, recorded: true }, { name: 'fs-write', hasModelTurn: true, recorded: true }, diff --git a/examples/acp-agent/tests/fs-search.cordis.snapshot.yml b/examples/acp-agent/tests/fs-search.cordis.snapshot.yml index 141691a087..5fcb2248f3 100644 --- a/examples/acp-agent/tests/fs-search.cordis.snapshot.yml +++ b/examples/acp-agent/tests/fs-search.cordis.snapshot.yml @@ -3,7 +3,7 @@ name: '@deepseek-ai/dsh-llm-replay' config: providers: - - id: deepseek + - id: deepseek-official name: DeepSeek models: - id: deepseek-v4-pro @@ -17,10 +17,14 @@ - id: acp-agent name: '@deepseek-ai/dsh-acp-demo' config: - provider: deepseek + provider: deepseek-official model: deepseek-v4-pro persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' persistenceCompression: none + # Unpacked rows: the eager-drain batch boundaries that split packed delta + # runs are timing-dependent, so packed logs cannot replay-match a live + # record of a long reasoning stream. + packChunks: false workspaceContext: false skills: enabled: false diff --git a/examples/acp-agent/tests/fs-search.cordis.yml b/examples/acp-agent/tests/fs-search.cordis.yml index 153128f914..0f6d5d9a63 100644 --- a/examples/acp-agent/tests/fs-search.cordis.yml +++ b/examples/acp-agent/tests/fs-search.cordis.yml @@ -16,9 +16,14 @@ - id: acp-agent name: '@deepseek-ai/dsh-acp-demo' config: - provider: deepseek + provider: deepseek-official model: deepseek-v4-pro persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" + # Unpacked rows: the eager-drain batch boundaries that split packed delta + # runs are timing-dependent, so packed logs cannot replay-match a live + # record of a long reasoning stream. + packChunks: false workspaceContext: false skills: enabled: false diff --git a/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl b/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl index ca51259632..f1c26ca911 100644 --- a/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl @@ -1,25 +1,120 @@ -{"type":"session","version":0,"id":"f5a99d52-3eaa-4ce7-858d-61d4fd77df2a","createdAt":1785218400000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1785218400001,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1785218400002,"data":{"content":[{"type":"text","text":"Call glob exactly once with pattern * and path tree. Then reply with exactly GLOB_SAMPLED and nothing else."}],"source":{"kind":"user"},"role":"user","id":"6790985f-1de2-42f8-a7f1-24e46d6439c7"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1785218400003,"data":{"title":"Call glob exactly once with","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":1785218400004,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1785218400005,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":5,"time":1785483397569,"data":{"provider":"deepseek","model":"deepseek-v4-pro"}} -{"type":"assistant/chunk","seq":6,"time":1785218400007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":7,"time":1785218400008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"glob-sampling-call","name":"glob","argumentsDelta":"{\"pattern\":\"*\",\"path\":\"tree\"}"}}} -{"type":"assistant/chunk","seq":8,"time":1785218400009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\",\"path\":\"tree\"}"}}}} -{"type":"assistant/chunk","seq":9,"time":1785218400010,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}} -{"type":"assistant/chunk","seq":10,"time":1785483397579,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":11,"time":1785483397579,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\",\"path\":\"tree\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-pro"},"id":"a127cfe5-39fb-462c-8e5a-a8c79bd0e52b"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[6,7,8,9,10],"surfaceOp":"append"} -{"type":"tool/call","seq":12,"time":1785483397579,"data":{"turn":1,"step":1,"callId":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\",\"path\":\"tree\"}"}} -{"type":"tool/result","seq":13,"time":1785483398062,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"glob-sampling-call"},"content":[{"type":"tool-result","toolCallId":"glob-sampling-call","content":[{"type":"text","text":"tree/archive/a.ts\ntree/docs/guide.md\ntree/src/index.ts\ntree/test/spec.ts\n\n(Showing 4 of 8 paths, sampled across 4 of the 6 top-level entries this pattern matched instead of taken in modification-time order. Narrow path to inspect a specific subtree. The complete result could not be saved; narrow pattern or path to see more.)"}],"isError":false}],"role":"user","id":"2beecb2e-627d-43dc-a936-03e1dc874093"},"meta":{"shape":"paths","paths":["tree/archive/a.ts","tree/docs/guide.md","tree/src/index.ts","tree/test/spec.ts"],"truncated":true,"total":8}},"sourceEventSeqs":[12],"surfaceOp":"append"} -{"type":"step/end","seq":14,"time":1785483398062,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":15,"time":1785483398072,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":16,"time":1785218400017,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":17,"time":1785218400018,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"GLOB_SAMPLED"}}} -{"type":"assistant/chunk","seq":18,"time":1785218400019,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GLOB_SAMPLED"}}}} -{"type":"assistant/chunk","seq":19,"time":1785218400020,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}} -{"type":"assistant/chunk","seq":20,"time":1785483398078,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":21,"time":1785483398078,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"GLOB_SAMPLED"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-pro"},"id":"ce2334a4-be71-490b-a502-29186a9ced5c"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"} -{"type":"step/end","seq":22,"time":1785483398078,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":23,"time":1785483398079,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"4428b809-66d5-4ea2-9a03-89de742fcda1","createdAt":1785591986068,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1785591986072,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1785591986073,"data":{"content":[{"type":"text","text":"Call glob exactly once with pattern * and path tree. Then reply with exactly GLOB_SAMPLED and nothing else."}],"source":{"kind":"user"},"role":"user","id":"3d05fb76-4185-460b-9c6a-8c1b2495bc9f"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785591986074,"data":{"title":"Call glob exactly once with","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785591986092,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785591986093,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":5,"time":1785591986094,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} +{"type":"assistant/chunk","seq":6,"time":1785591987500,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":7,"time":1785591987500,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":8,"time":1785591987529,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":9,"time":1785591987587,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":10,"time":1785591987588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":11,"time":1785591987588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":12,"time":1785591987588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" call"}}} +{"type":"assistant/chunk","seq":13,"time":1785591987588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" glob"}}} +{"type":"assistant/chunk","seq":14,"time":1785591987639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":15,"time":1785591987639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} +{"type":"assistant/chunk","seq":16,"time":1785591987639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":17,"time":1785591987685,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" pattern"}}} +{"type":"assistant/chunk","seq":18,"time":1785591987685,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" *"}}} +{"type":"assistant/chunk","seq":19,"time":1785591987876,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":20,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" path"}}} +{"type":"assistant/chunk","seq":21,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tree"}}} +{"type":"assistant/chunk","seq":22,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":23,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":24,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":25,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":26,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":27,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":28,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"G"}}} +{"type":"assistant/chunk","seq":29,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"LOB"}}} +{"type":"assistant/chunk","seq":30,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_S"}}} +{"type":"assistant/chunk","seq":31,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AM"}}} +{"type":"assistant/chunk","seq":32,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"PL"}}} +{"type":"assistant/chunk","seq":33,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ED"}}} +{"type":"assistant/chunk","seq":34,"time":1785591987977,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":35,"time":1785591988034,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":36,"time":1785591988035,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":37,"time":1785591988090,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":38,"time":1785591988090,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":39,"time":1785591988090,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"pattern"}}} +{"type":"assistant/chunk","seq":40,"time":1785591988091,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":41,"time":1785591988136,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":42,"time":1785591988136,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":43,"time":1785591988136,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"*"}}} +{"type":"assistant/chunk","seq":44,"time":1785591988193,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":45,"time":1785591988207,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":46,"time":1785591988207,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":47,"time":1785591988207,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"path"}}} +{"type":"assistant/chunk","seq":48,"time":1785591988207,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":49,"time":1785591988207,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":50,"time":1785591988284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":51,"time":1785591988284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"tree"}}} +{"type":"assistant/chunk","seq":52,"time":1785591988284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":53,"time":1785591988338,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":54,"time":1785591988427,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to call glob exactly once with pattern * and path tree, then reply with exactly \"GLOB_SAMPLED\"."}}}} +{"type":"assistant/chunk","seq":55,"time":1785591988427,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","arguments":"{\"pattern\": \"*\", \"path\": \"tree\"}"}}}} +{"type":"assistant/chunk","seq":56,"time":1785591988427,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1286,"outputTokens":87,"cacheReadTokens":0,"reasoningTokens":28}}}} +{"type":"assistant/chunk","seq":57,"time":1785591988427,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":58,"time":1785591988430,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to call glob exactly once with pattern * and path tree, then reply with exactly \"GLOB_SAMPLED\"."},{"type":"tool-call","id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","arguments":"{\"pattern\": \"*\", \"path\": \"tree\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"b74cbab2-c017-4e44-8c09-a7745d8b274a"},"usage":{"inputTokens":1286,"outputTokens":87,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57],"surfaceOp":"append"} +{"type":"tool/call","seq":59,"time":1785591988431,"data":{"turn":1,"step":1,"callId":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","arguments":"{\"pattern\": \"*\", \"path\": \"tree\"}"}} +{"type":"tool/result","seq":60,"time":1785591988476,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_1cLZjkCW0vxVw0e3xVfh3430"},"content":[{"type":"tool-result","toolCallId":"call_00_1cLZjkCW0vxVw0e3xVfh3430","content":[{"type":"text","text":"tree/archive/a.ts\ntree/docs/guide.md\ntree/src/index.ts\ntree/test/spec.ts\n\n(Showing 4 of 8 paths, sampled across 4 of the 6 top-level entries this pattern matched instead of taken in modification-time order. Narrow path to inspect a specific subtree. The complete result could not be saved; narrow pattern or path to see more.)"}],"isError":false}],"role":"user","id":"10284f88-4890-49ed-9a17-56edbd6bfaa7"},"meta":{"shape":"paths","paths":["tree/archive/a.ts","tree/docs/guide.md","tree/src/index.ts","tree/test/spec.ts"],"truncated":true,"total":8}},"sourceEventSeqs":[59],"surfaceOp":"append"} +{"type":"step/end","seq":61,"time":1785591988476,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":62,"time":1785591988482,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":63,"time":1785591989939,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":64,"time":1785591989939,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":65,"time":1785591989939,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" glob"}}} +{"type":"assistant/chunk","seq":66,"time":1785591989939,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":67,"time":1785591989939,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" shows"}}} +{"type":"assistant/chunk","seq":68,"time":1785591989988,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":69,"time":1785591990024,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":70,"time":1785591990127,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sampled"}}} +{"type":"assistant/chunk","seq":71,"time":1785591990128,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" -"}}} +{"type":"assistant/chunk","seq":72,"time":1785591990128,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":73,"time":1785591990128,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"4"}}} +{"type":"assistant/chunk","seq":74,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}} +{"type":"assistant/chunk","seq":75,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":76,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"8"}}} +{"type":"assistant/chunk","seq":77,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" paths"}}} +{"type":"assistant/chunk","seq":78,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" across"}}} +{"type":"assistant/chunk","seq":79,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":80,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"4"}}} +{"type":"assistant/chunk","seq":81,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}} +{"type":"assistant/chunk","seq":82,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":83,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"6"}}} +{"type":"assistant/chunk","seq":84,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" top"}}} +{"type":"assistant/chunk","seq":85,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"-level"}}} +{"type":"assistant/chunk","seq":86,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" entries"}}} +{"type":"assistant/chunk","seq":87,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":88,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":89,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":90,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":91,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":92,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":93,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":94,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":95,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"G"}}} +{"type":"assistant/chunk","seq":96,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"LOB"}}} +{"type":"assistant/chunk","seq":97,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_S"}}} +{"type":"assistant/chunk","seq":98,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AM"}}} +{"type":"assistant/chunk","seq":99,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"PL"}}} +{"type":"assistant/chunk","seq":100,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ED"}}} +{"type":"assistant/chunk","seq":101,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":102,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} +{"type":"assistant/chunk","seq":103,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}} +{"type":"assistant/chunk","seq":104,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":105,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":106,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"G"}}} +{"type":"assistant/chunk","seq":107,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"LOB"}}} +{"type":"assistant/chunk","seq":108,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_S"}}} +{"type":"assistant/chunk","seq":109,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"AM"}}} +{"type":"assistant/chunk","seq":110,"time":1785591990518,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"PL"}}} +{"type":"assistant/chunk","seq":111,"time":1785591990518,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ED"}}} +{"type":"assistant/chunk","seq":112,"time":1785591990526,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The glob result shows it was sampled - 4 of 8 paths across 4 of 6 top-level entries. I need to reply with exactly \"GLOB_SAMPLED\" as instructed."}}}} +{"type":"assistant/chunk","seq":113,"time":1785591990527,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"GLOB_SAMPLED"}}}} +{"type":"assistant/chunk","seq":114,"time":1785591990527,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":188,"outputTokens":48,"cacheReadTokens":1280,"reasoningTokens":41}}}} +{"type":"assistant/chunk","seq":115,"time":1785591990527,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":116,"time":1785591990527,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The glob result shows it was sampled - 4 of 8 paths across 4 of 6 top-level entries. I need to reply with exactly \"GLOB_SAMPLED\" as instructed."},{"type":"text","text":"GLOB_SAMPLED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"dd3a9c28-43b2-4fdc-8089-1547309a71c0"},"usage":{"inputTokens":188,"outputTokens":48,"cacheReadTokens":1280,"reasoningTokens":41}},"sourceEventSeqs":[63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115],"surfaceOp":"append"} +{"type":"step/end","seq":117,"time":1785591990527,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":118,"time":1785591990528,"data":{"turn":1,"reason":{"kind":"completed"}}} From a27ca7b88f46d461a4549dba1d9d0272b785bd21 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 1 Aug 2026 22:34:16 +0800 Subject: [PATCH 25/52] test(fs-search): keep the glob-sampling fixture in canonical packed layout The session-fixture-layout gate requires every session JSONL fixture in the canonical packed layout (maximal delta runs per kind/block, as migrate:packed-session-fixtures rewrites); the packChunks: false knob violated that invariant and failed the snapshot job. Revert the knob and canonicalize the recorded fixture instead: the live log's eager-drain-packed rows migrate to the maximal-run layout a burst replay reproduces, so the fixture stays replay-deterministic and canonical. --- examples/acp-agent/tests/acp.snapshot.ts | 14 +-- .../tests/fs-search.cordis.snapshot.yml | 4 - examples/acp-agent/tests/fs-search.cordis.yml | 4 - .../snapshots/fs-glob-sampling/session.jsonl | 97 +------------------ 4 files changed, 11 insertions(+), 108 deletions(-) diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index abdf10e367..326a7ee0c0 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -182,13 +182,13 @@ const SCENARIOS: Scenario[] = [ // on a host-installed ripgrep binary or a PATH stand-in. POSIX-only because // the displayed paths carry `/` separators the session-log comparison // cannot normalize. Recorded (not authored): the assistant turn is a real - // model transcript; re-record with `test:snapshot:record -t fs-glob-sampling`. - // The composition disables packed chunk rows (fs-search.cordis.yml), whose - // run boundaries depend on eager-drain timing, and the recorded fixture's - // `request/header` config and `request/context` are normalized to the - // replay-produced minimal shape (the live adapter logs model capabilities - // like maxTokens/reasoningEffort that llm-replay has no data for), and its - // tool-result paths are canonicalized to `/` separators. + // model transcript; re-record with `test:snapshot:record -t fs-glob-sampling` + // and then `migrate:packed-session-fixtures`, which canonicalizes the live + // log's eager-drain-packed rows into the maximal-run layout replay produces. + // The recorded fixture's `request/header` config and `request/context` are + // normalized to the replay-produced minimal shape (the live adapter logs + // model capabilities like maxTokens/reasoningEffort that llm-replay has no + // data for), and its tool-result paths are canonicalized to `/` separators. { name: 'fs-glob-sampling', hasModelTurn: true, diff --git a/examples/acp-agent/tests/fs-search.cordis.snapshot.yml b/examples/acp-agent/tests/fs-search.cordis.snapshot.yml index 5fcb2248f3..0db692f5bd 100644 --- a/examples/acp-agent/tests/fs-search.cordis.snapshot.yml +++ b/examples/acp-agent/tests/fs-search.cordis.snapshot.yml @@ -21,10 +21,6 @@ model: deepseek-v4-pro persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' persistenceCompression: none - # Unpacked rows: the eager-drain batch boundaries that split packed delta - # runs are timing-dependent, so packed logs cannot replay-match a live - # record of a long reasoning stream. - packChunks: false workspaceContext: false skills: enabled: false diff --git a/examples/acp-agent/tests/fs-search.cordis.yml b/examples/acp-agent/tests/fs-search.cordis.yml index 0f6d5d9a63..c86b34b8aa 100644 --- a/examples/acp-agent/tests/fs-search.cordis.yml +++ b/examples/acp-agent/tests/fs-search.cordis.yml @@ -20,10 +20,6 @@ model: deepseek-v4-pro persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" - # Unpacked rows: the eager-drain batch boundaries that split packed delta - # runs are timing-dependent, so packed logs cannot replay-match a live - # record of a long reasoning stream. - packChunks: false workspaceContext: false skills: enabled: false diff --git a/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl b/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl index f1c26ca911..d85c0ea447 100644 --- a/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl @@ -6,53 +6,9 @@ {"type":"request/header","seq":4,"time":1785591986093,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":5,"time":1785591986094,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} {"type":"assistant/chunk","seq":6,"time":1785591987500,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":7,"time":1785591987500,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":8,"time":1785591987529,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":9,"time":1785591987587,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":10,"time":1785591987588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":11,"time":1785591987588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":12,"time":1785591987588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" call"}}} -{"type":"assistant/chunk","seq":13,"time":1785591987588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" glob"}}} -{"type":"assistant/chunk","seq":14,"time":1785591987639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":15,"time":1785591987639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} -{"type":"assistant/chunk","seq":16,"time":1785591987639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":17,"time":1785591987685,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" pattern"}}} -{"type":"assistant/chunk","seq":18,"time":1785591987685,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" *"}}} -{"type":"assistant/chunk","seq":19,"time":1785591987876,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":20,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" path"}}} -{"type":"assistant/chunk","seq":21,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tree"}}} -{"type":"assistant/chunk","seq":22,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":23,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} -{"type":"assistant/chunk","seq":24,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":25,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":26,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":27,"time":1785591987877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":28,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"G"}}} -{"type":"assistant/chunk","seq":29,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"LOB"}}} -{"type":"assistant/chunk","seq":30,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_S"}}} -{"type":"assistant/chunk","seq":31,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AM"}}} -{"type":"assistant/chunk","seq":32,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"PL"}}} -{"type":"assistant/chunk","seq":33,"time":1785591987878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ED"}}} -{"type":"assistant/chunk","seq":34,"time":1785591987977,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"reasoning-chunks","seq0":7,"time0":1785591987500,"data":{"turn":1,"step":1,"index":0,"dt":[29,58,1,0,0,0,51,0,0,46,0,191,1,0,0,0,0,0,0,0,1,0,0,0,0,0,99],"texts":["The"," user"," wants"," me"," to"," call"," glob"," exactly"," once"," with"," pattern"," *"," and"," path"," tree",","," then"," reply"," with"," exactly"," \"","G","LOB","_S","AM","PL","ED","\"."]}} {"type":"assistant/chunk","seq":35,"time":1785591988034,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":36,"time":1785591988035,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":37,"time":1785591988090,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":38,"time":1785591988090,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":39,"time":1785591988090,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"pattern"}}} -{"type":"assistant/chunk","seq":40,"time":1785591988091,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":41,"time":1785591988136,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":42,"time":1785591988136,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":43,"time":1785591988136,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"*"}}} -{"type":"assistant/chunk","seq":44,"time":1785591988193,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":45,"time":1785591988207,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":46,"time":1785591988207,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":47,"time":1785591988207,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"path"}}} -{"type":"assistant/chunk","seq":48,"time":1785591988207,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":49,"time":1785591988207,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":50,"time":1785591988284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":51,"time":1785591988284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"tree"}}} -{"type":"assistant/chunk","seq":52,"time":1785591988284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":53,"time":1785591988338,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","argumentsDelta":"}"}}} +{"type":"tool-call-chunks","seq0":36,"time0":1785591988035,"data":{"turn":1,"step":1,"index":1,"dt":[55,0,0,1,45,0,0,57,14,0,0,0,0,77,0,0,54],"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","args":["","{","\"","pattern","\"",": ","\"","*","\"",", ","\"","path","\"",": ","\"","tree","\"","}"]}} {"type":"assistant/chunk","seq":54,"time":1785591988427,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to call glob exactly once with pattern * and path tree, then reply with exactly \"GLOB_SAMPLED\"."}}}} {"type":"assistant/chunk","seq":55,"time":1785591988427,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","arguments":"{\"pattern\": \"*\", \"path\": \"tree\"}"}}}} {"type":"assistant/chunk","seq":56,"time":1785591988427,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1286,"outputTokens":87,"cacheReadTokens":0,"reasoningTokens":28}}}} @@ -63,54 +19,9 @@ {"type":"step/end","seq":61,"time":1785591988476,"data":{"turn":1,"step":1}} {"type":"step/start","seq":62,"time":1785591988482,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":63,"time":1785591989939,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":64,"time":1785591989939,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":65,"time":1785591989939,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" glob"}}} -{"type":"assistant/chunk","seq":66,"time":1785591989939,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} -{"type":"assistant/chunk","seq":67,"time":1785591989939,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" shows"}}} -{"type":"assistant/chunk","seq":68,"time":1785591989988,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":69,"time":1785591990024,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} -{"type":"assistant/chunk","seq":70,"time":1785591990127,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sampled"}}} -{"type":"assistant/chunk","seq":71,"time":1785591990128,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" -"}}} -{"type":"assistant/chunk","seq":72,"time":1785591990128,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} -{"type":"assistant/chunk","seq":73,"time":1785591990128,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"4"}}} -{"type":"assistant/chunk","seq":74,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}} -{"type":"assistant/chunk","seq":75,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} -{"type":"assistant/chunk","seq":76,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"8"}}} -{"type":"assistant/chunk","seq":77,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" paths"}}} -{"type":"assistant/chunk","seq":78,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" across"}}} -{"type":"assistant/chunk","seq":79,"time":1785591990454,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} -{"type":"assistant/chunk","seq":80,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"4"}}} -{"type":"assistant/chunk","seq":81,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}} -{"type":"assistant/chunk","seq":82,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} -{"type":"assistant/chunk","seq":83,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"6"}}} -{"type":"assistant/chunk","seq":84,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" top"}}} -{"type":"assistant/chunk","seq":85,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"-level"}}} -{"type":"assistant/chunk","seq":86,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" entries"}}} -{"type":"assistant/chunk","seq":87,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":88,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":89,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} -{"type":"assistant/chunk","seq":90,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":91,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":92,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":93,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":94,"time":1785591990455,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":95,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"G"}}} -{"type":"assistant/chunk","seq":96,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"LOB"}}} -{"type":"assistant/chunk","seq":97,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_S"}}} -{"type":"assistant/chunk","seq":98,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AM"}}} -{"type":"assistant/chunk","seq":99,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"PL"}}} -{"type":"assistant/chunk","seq":100,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ED"}}} -{"type":"assistant/chunk","seq":101,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":102,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} -{"type":"assistant/chunk","seq":103,"time":1785591990456,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}} -{"type":"assistant/chunk","seq":104,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"reasoning-chunks","seq0":64,"time0":1785591989939,"data":{"turn":1,"step":2,"index":0,"dt":[0,0,0,49,36,103,1,0,0,326,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,14],"texts":["The"," glob"," result"," shows"," it"," was"," sampled"," -"," ","4"," of"," ","8"," paths"," across"," ","4"," of"," ","6"," top","-level"," entries","."," I"," need"," to"," reply"," with"," exactly"," \"","G","LOB","_S","AM","PL","ED","\""," as"," instructed","."]}} {"type":"assistant/chunk","seq":105,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":106,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"G"}}} -{"type":"assistant/chunk","seq":107,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"LOB"}}} -{"type":"assistant/chunk","seq":108,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_S"}}} -{"type":"assistant/chunk","seq":109,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"AM"}}} -{"type":"assistant/chunk","seq":110,"time":1785591990518,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"PL"}}} -{"type":"assistant/chunk","seq":111,"time":1785591990518,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ED"}}} +{"type":"text-chunks","seq0":106,"time0":1785591990470,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,0,48,0],"texts":["G","LOB","_S","AM","PL","ED"]}} {"type":"assistant/chunk","seq":112,"time":1785591990526,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The glob result shows it was sampled - 4 of 8 paths across 4 of 6 top-level entries. I need to reply with exactly \"GLOB_SAMPLED\" as instructed."}}}} {"type":"assistant/chunk","seq":113,"time":1785591990527,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"GLOB_SAMPLED"}}}} {"type":"assistant/chunk","seq":114,"time":1785591990527,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":188,"outputTokens":48,"cacheReadTokens":1280,"reasoningTokens":41}}}} From 601fb9d1952ea07a6bba070ad1887c2b0890136e Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 01:11:08 +0800 Subject: [PATCH 26/52] fix(fs-search): address the second-round #1119 review - inline the collect() identity wrapper now that both streams use the seam's diagnostic-tail shape - resolve the packaged rg path lazily at the first call (memoized): @vscode/ripgrep resolves its platform package at module evaluation, so a static import turned a missing/corrupt platform package into a Loader composition failure instead of the documented per-call SEARCH_FAILED - classify synchronous spawn-creation throws (a NUL in argv, an abort racing the pre-check, a rejected resolution) into SEARCH_FAILED / SEARCH_ABORTED instead of leaking raw errors - correct the stderrMaxBytes contract: the stderr excerpt is embedded in SEARCH_* error messages, not hidden from the model - export virtualManifest and pin its three acceptance paths (prefix hit, pnpm-11 truncated-name content-scan fallback, both miss) with fixture unit tests Tests: rg-path.spec.ts (resolution failure + memoized rejection), tools.spec.ts spawn-creation classification, notices spec virtualManifest. --- ...26-08-01-packaged-ripgrep-search.i18n.yaml | 4 +- .../2026-08-01-packaged-ripgrep-search.md | 2 +- .../2026-08-01-packaged-ripgrep-search.zh.md | 2 +- docs/config-catalog.md | 4 +- packages/fs/tool-fs-search/src/index.ts | 3 +- packages/fs/tool-fs-search/src/search-core.ts | 70 ++++++++++++++----- .../fs/tool-fs-search/tests/rg-path.spec.ts | 37 ++++++++++ .../fs/tool-fs-search/tests/tools.spec.ts | 43 ++++++++++++ scripts/gen-third-party-notices.spec.ts | 57 ++++++++++++++- scripts/gen-third-party-notices.ts | 7 +- 10 files changed, 200 insertions(+), 29 deletions(-) create mode 100644 packages/fs/tool-fs-search/tests/rg-path.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml index 3647587fa5..f63372c8b4 100644 --- a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md -2026-08-01-packaged-ripgrep-search.md: 849cc0804a2081492297649ac8f13236e2ac60fe -2026-08-01-packaged-ripgrep-search.zh.md: 4381c3a8bca8e6bb9ab375ad352fec5a5ddb9f99 +2026-08-01-packaged-ripgrep-search.md: 7c515618a18b61bd90177a6fdf19bbd52e564209 +2026-08-01-packaged-ripgrep-search.zh.md: f2b1a12c737f772bff6a6c91c17f7453dbc89748 diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md index 849cc0804a..7c515618a1 100644 --- a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md @@ -12,7 +12,7 @@ The `glob`/`grep` tools ran through the bash executor seam, which made a system ## Decision -`@deepseek-ai/dsh-tool-fs-search` now runs the PACKAGED ripgrep binary (`@vscode/ripgrep`, an npm dependency whose optional platform packages ship the binary) through the `ctx.subprocess` seam: `runRipgrep()` spawns `rgPath` with a plain argv vector prefixed by `--no-config`, collect-mode stdout/stderr, `graceMs`, and `exec.signal` forwarded. There is no shell layer, so the shell-quoting boundary is gone from execution; the `singleQuote` helper and its shell-spawning tests are deleted with it. The raw streams request the seam's diagnostic-tail collect shape (no spill files — the tool never reads a raw spill path; a lossy stdout read fails as `SEARCH_RAW_OUTPUT_OVERFLOW`). The terminate grace and the stderr tail budget are validated `Config` fields (`graceMs` default 3000, `stderrMaxBytes` default 64 KiB), no longer inherited from bash-local's config. Registration is unconditional — the load-time `command -v rg` probe and the conditional registration decision are deleted, and with them the "rg not found" warning. The package injects `tools`, `systemPrompt`, and `subprocess`. +`@deepseek-ai/dsh-tool-fs-search` now runs the PACKAGED ripgrep binary (`@vscode/ripgrep`, an npm dependency whose optional platform packages ship the binary) through the `ctx.subprocess` seam: `runRipgrep()` spawns `rgPath` with a plain argv vector prefixed by `--no-config`, collect-mode stdout/stderr, `graceMs`, and `exec.signal` forwarded. `rgPath` resolves lazily at the first call (memoized per process): `@vscode/ripgrep` resolves its platform package at module evaluation, so a static import would turn a missing or corrupt platform package (`--omit=optional`, partial install) into a Loader-composition failure — the load-time failure mode this change exists to remove. There is no shell layer, so the shell-quoting boundary is gone from execution; the `singleQuote` helper and its shell-spawning tests are deleted with it. The raw streams request the seam's diagnostic-tail collect shape (no spill files — the tool never reads a raw spill path; a lossy stdout read fails as `SEARCH_RAW_OUTPUT_OVERFLOW`). The terminate grace and the stderr tail budget are validated `Config` fields (`graceMs` default 3000, `stderrMaxBytes` default 64 KiB), no longer inherited from bash-local's config. Registration is unconditional — the load-time `command -v rg` probe and the conditional registration decision are deleted, and with them the "rg not found" warning. The package injects `tools`, `systemPrompt`, and `subprocess`. Exit semantics stay tool-owned: exit 0 is success with results, exit 1 is a successful empty search, anything else classifies into the existing `SEARCH_*` vocabulary (invalid pattern, launch failure, signal kill, raw-output overflow). Timeout is the cooperative tool-call budget attached to the tool definitions: `@deepseek-ai/dsh-timeout-policy` aborts `exec.signal`, the subprocess seam's terminate escalation provides the hard kill, and the tool reports `SEARCH_ABORTED`. The working directory is the session header cwd when present, else `process.cwd()` — there is no executor config to default through anymore, so the tool owns the fallback. diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md index 4381c3a8bc..f2b1a12c73 100644 --- a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -`@deepseek-ai/dsh-tool-fs-search` 现在运行 PACKAGED(打包的)ripgrep 二进制(`@vscode/ripgrep`,一个 npm 依赖,其可选平台包随附二进制),经由 `ctx.subprocess` seam:`runRipgrep()` 以纯 argv 向量 spawn `rgPath`,向量前缀 `--no-config`,配以 collect 模式 stdout/stderr、`graceMs` 与转发的 `exec.signal`。不再有 shell 层,执行路径上的 shell 引号边界随之消失;`singleQuote` 工具与其 shell spawn 测试一并删除。原始流使用 seam 的诊断尾部 collect 形态(无 spill 文件——工具从不读取原始 spill 路径;lossy stdout 读取以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败)。终止宽限与 stderr 尾部预算成为经校验的 `Config` 字段(`graceMs` 默认 3000,`stderrMaxBytes` 默认 64 KiB),不再继承自 bash-local 的配置。注册变为无条件——加载期 `command -v rg` 探针与条件注册决策被删除,连同那条 "rg not found" 警告。本包注入 `tools`、`systemPrompt` 与 `subprocess`。 +`@deepseek-ai/dsh-tool-fs-search` 现在运行 PACKAGED(打包的)ripgrep 二进制(`@vscode/ripgrep`,一个 npm 依赖,其可选平台包随附二进制),经由 `ctx.subprocess` seam:`runRipgrep()` 以纯 argv 向量 spawn `rgPath`,向量前缀 `--no-config`,配以 collect 模式 stdout/stderr、`graceMs` 与转发的 `exec.signal`。`rgPath` 在首次调用时懒解析(进程内 memoize):`@vscode/ripgrep` 在模块求值阶段解析其平台包,静态导入会把平台包缺失/损坏(`--omit=optional`、安装不全)变成 Loader 组合加载失败——这正是本次改动要消除的加载期失败模式。不再有 shell 层,执行路径上的 shell 引号边界随之消失;`singleQuote` 工具与其 shell spawn 测试一并删除。原始流使用 seam 的诊断尾部 collect 形态(无 spill 文件——工具从不读取原始 spill 路径;lossy stdout 读取以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败)。终止宽限与 stderr 尾部预算成为经校验的 `Config` 字段(`graceMs` 默认 3000,`stderrMaxBytes` 默认 64 KiB),不再继承自 bash-local 的配置。注册变为无条件——加载期 `command -v rg` 探针与条件注册决策被删除,连同那条 "rg not found" 警告。本包注入 `tools`、`systemPrompt` 与 `subprocess`。 退出语义仍由工具拥有:退出码 0 为有结果的成功,1 为成功的空搜索,其余归入既有 `SEARCH_*` 词汇(无效模式、启动失败、信号杀死、原始输出溢出)。超时是挂在工具定义上的协作式工具调用预算:`@deepseek-ai/dsh-timeout-policy` 中止 `exec.signal`,subprocess seam 的终止升级提供硬终止,工具报告 `SEARCH_ABORTED`。工作目录为会话 header cwd(存在时),否则为 `process.cwd()`——不再有执行器配置可供默认化,因此回退由工具自己拥有。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a37e8da1c9..c568634985 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1735,14 +1735,14 @@ export interface Config { rawOutputMaxBytes?: number /** Terminate-escalation grace period (ms) for one search process, handed to the subprocess seam. */ graceMs?: number - /** Max bytes retained for one search's stderr diagnostic tail (never surfaced to the model). */ + /** Max bytes retained for one search's stderr tail; the excerpt is embedded in `SEARCH_*` error messages, never shown on success. */ stderrMaxBytes?: number /** Cooperative tool-call timeout budget (ms) on both tools, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`. */ timeoutMs?: number } ``` -Source: [`packages/fs/tool-fs-search/src/index.ts:71`](../packages/fs/tool-fs-search/src/index.ts) +Source: [`packages/fs/tool-fs-search/src/index.ts:72`](../packages/fs/tool-fs-search/src/index.ts) ## `@deepseek-ai/dsh-tool-goal` diff --git a/packages/fs/tool-fs-search/src/index.ts b/packages/fs/tool-fs-search/src/index.ts index 9a4042dde1..7f8e43cb73 100644 --- a/packages/fs/tool-fs-search/src/index.ts +++ b/packages/fs/tool-fs-search/src/index.ts @@ -55,6 +55,7 @@ export { SEARCH_TIMEOUT_MS, SearchError, previewLine, + resolveRgPath, runRipgrep, toWorkdirRelative, trySaveFormattedResult, @@ -83,7 +84,7 @@ export interface Config { rawOutputMaxBytes?: number /** Terminate-escalation grace period (ms) for one search process, handed to the subprocess seam. */ graceMs?: number - /** Max bytes retained for one search's stderr diagnostic tail (never surfaced to the model). */ + /** Max bytes retained for one search's stderr tail; the excerpt is embedded in `SEARCH_*` error messages, never shown on success. */ stderrMaxBytes?: number /** Cooperative tool-call timeout budget (ms) on both tools, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`. */ timeoutMs?: number diff --git a/packages/fs/tool-fs-search/src/search-core.ts b/packages/fs/tool-fs-search/src/search-core.ts index 9444b029cc..854c593190 100644 --- a/packages/fs/tool-fs-search/src/search-core.ts +++ b/packages/fs/tool-fs-search/src/search-core.ts @@ -21,11 +21,10 @@ import { isAbsolute, relative, sep } from 'node:path' import type { Context } from 'cordis' -import { rgPath } from '@vscode/ripgrep' import { HarnessError } from '@deepseek-ai/dsh-llm' import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-retention' import type { RetainedItems } from '@deepseek-ai/dsh-retention' -import type { SubprocessCollect, SubprocessOutcome, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessHandle, SubprocessOutcome, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill' import type { ToolExecution } from '@deepseek-ai/dsh-tools' @@ -154,6 +153,26 @@ function completeStdout(toolName: string, stdout: SubprocessOutputRead, rawOutpu ) } +let rgPathPromise: Promise | undefined + +/** + * The packaged ripgrep binary path, resolved lazily once per process. + * + * `@vscode/ripgrep` resolves its platform package (`@vscode/ripgrep- + * -`) at module evaluation, so a static import would turn a missing or + * corrupt platform package (`pnpm install --omit=optional`, partial install) + * into a failure of the whole Loader composition. Resolving at the call + * boundary keeps that failure at the first search call as `SEARCH_FAILED` — + * the package's documented no-load-time-probe contract. + * + * @returns the packaged binary's absolute path; the memoized promise rejects + * when the platform package cannot be resolved. + */ +export function resolveRgPath(): Promise { + rgPathPromise ??= import('@vscode/ripgrep').then(module => module.rgPath) + return rgPathPromise +} + /** * Run the packaged ripgrep binary with a plain argv vector and return its * complete raw stdout. The working directory is the calling agent's session @@ -173,9 +192,12 @@ function completeStdout(toolName: string, stdout: SubprocessOutputRead, rawOutpu * success with zero results (`noMatches`), anything else throws a * {@link SearchError} (abort/timeout → `SEARCH_ABORTED`, invalid pattern → * `SEARCH_INVALID_PATTERN`, the rest → `SEARCH_FAILED` / - * `SEARCH_RAW_OUTPUT_OVERFLOW`). A spawn REJECTION — the seam's - * infrastructure failures — is translated into `SEARCH_FAILED` with the - * original as `cause`; a pre-aborted signal becomes `SEARCH_ABORTED`. + * `SEARCH_RAW_OUTPUT_OVERFLOW`). Both launch-time failure domains are + * classified: a synchronous throw at spawn CREATION (a NUL in argv, an abort + * racing the pre-check, a rejected `@vscode/ripgrep` resolution) and a + * rejection of `handle.done` (the seam's infrastructure failures) both become + * `SEARCH_FAILED` with the original as `cause` — an abort already observed by + * creation time becomes `SEARCH_ABORTED` instead. * * @param ctx - the plugin context; execution uses its `subprocess` service. * @param exec - the tool-execution context; supplies the session cwd and the abort signal. @@ -200,19 +222,31 @@ export async function runRipgrep( } const cwd = exec.agent?.session.header.cwd const workdir = cwd ?? process.cwd() - const collect = (maxBytes: number): SubprocessCollect => - ({ maxBytes }) - const handle = ctx.subprocess.spawn({ - argv: [rgPath, '--no-config', ...argv], - cwd: workdir, - stdio: { - stdin: 'ignore', - stdout: collect(rawOutputMaxBytes), - stderr: collect(stderrMaxBytes), - }, - graceMs, - signal: exec.signal, - } satisfies SubprocessSpawnSpec) + let handle: SubprocessHandle + try { + handle = ctx.subprocess.spawn({ + argv: [await resolveRgPath(), '--no-config', ...argv], + cwd: workdir, + stdio: { + stdin: 'ignore', + stdout: { maxBytes: rawOutputMaxBytes }, + stderr: { maxBytes: stderrMaxBytes }, + }, + graceMs, + signal: exec.signal, + } satisfies SubprocessSpawnSpec) + } catch (error: unknown) { + // Node's spawn() throws synchronously for a NUL in argv, and the local + // impl can throw synchronously when the signal aborts between the check + // above and this call (or when the platform-package resolution rejects). + // The static narrowing that proves this re-check "always false" cannot + // see AbortSignal state changes. + // oxlint-disable-next-line typescript/no-unnecessary-condition + if (exec.signal.aborted) { + throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED') + } + throw new SearchError(`${toolName} could not start its search command (ripgrep launch failed)`, 'SEARCH_FAILED', { cause: error }) + } let outcome: SubprocessOutcome try { outcome = await handle.done diff --git a/packages/fs/tool-fs-search/tests/rg-path.spec.ts b/packages/fs/tool-fs-search/tests/rg-path.spec.ts new file mode 100644 index 0000000000..52888a3453 --- /dev/null +++ b/packages/fs/tool-fs-search/tests/rg-path.spec.ts @@ -0,0 +1,37 @@ +/** + * Failure-path tests for the lazy packaged-ripgrep resolution. The success + * path (the real `@vscode/ripgrep` module) is exercised throughout + * tools.spec.ts; here the module is mocked to throw at evaluation, proving a + * missing or corrupt platform package (`--omit=optional`, partial install) + * surfaces as a per-call `SEARCH_FAILED` — not a composition-load failure. + */ + +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import type { ToolExecution } from '@deepseek-ai/dsh-tools' +import { resolveRgPath, runRipgrep } from '@deepseek-ai/dsh-tool-fs-search' + +// Any access to the mocked module's surface throws — the shape a missing +// platform package produces at module evaluation. +vi.mock('@vscode/ripgrep', () => new Proxy({}, { + get() { + throw new Error('platform package @vscode/ripgrep-win32-x64 is not installed') + }, +})) + +describe('lazy packaged-ripgrep resolution', () => { + it('fails the first search call with SEARCH_FAILED instead of failing module load', async () => { + // The resolution rejects before any spawn, so no subprocess service is needed. + const controller = new AbortController() + const exec = { signal: controller.signal, name: 'glob', callId: CallId('missing-platform-package') } as unknown as ToolExecution + + await expect(runRipgrep(new Context(), exec, 'glob', ['--files'], 1_000_000, 3_000, 64 * 1024)) + .rejects.toMatchObject({ name: 'SearchError', code: 'SEARCH_FAILED' }) + }) + + it('keeps failing every subsequent call (the resolution is memoized)', async () => { + await expect(resolveRgPath()).rejects.toThrow(/platform package/) + await expect(resolveRgPath()).rejects.toThrow(/platform package/) + }) +}) diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index 5c6713a7f0..a8a2498c60 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -32,6 +32,7 @@ import { presentGrepCall, presentGrepResult, previewLine, + resolveRgPath, runRipgrep, sampleAcrossTopLevel, toWorkdirRelative, @@ -468,6 +469,48 @@ describe('workdir derivation and signal forwarding', () => { expect(text(result)).toContain('could not start') }) + it('classifies a synchronous spawn-creation throw as SEARCH_FAILED', async () => { + // Node's spawn() throws synchronously for a NUL in argv, and the local + // impl can throw synchronously for other invalid specs. Creation-time + // failures must join the error vocabulary instead of escaping raw. + const { ctx, subprocess } = await setup() + subprocess.handler = () => { throw new Error('spawn ERR_INVALID_ARG_VALUE') } + + const result = await call(ctx, 'grep', { pattern: 'x' }) + + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } }) + expect(text(result)).toContain('could not start') + }) + + it('classifies a synchronous spawn-creation throw after an abort as SEARCH_ABORTED', async () => { + // The local impl can throw synchronously when the signal aborts between + // the pre-spawn check and the spawn call; no process was launched, so the + // abort is the reportable cause. + const { ctx, subprocess } = await setup() + const controller = new AbortController() + subprocess.handler = () => { + controller.abort('timeout') + throw new Error('aborted during spawn') + } + + const result = await call(ctx, 'glob', { pattern: '*' }, { signal: controller.signal }) + + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_ABORTED' } }) + expect(text(result)).toContain('aborted before completion') + }) + + it('resolves the packaged ripgrep path lazily, once per process', async () => { + // The module must not touch @vscode/ripgrep at load (a missing platform + // package would otherwise fail the whole composition), and repeated + // resolution reuses the first result. The resolution-failure path is + // pinned separately in rg-path.spec.ts. + await setup() + expect(await resolveRgPath()).toBe(rgPath) + expect(resolveRgPath()).toBe(resolveRgPath()) + }) + it('rejects when the subprocess implementation drops a requested collect stream', async () => { const { ctx, subprocess } = await setup() subprocess.dropReaders = true diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index 707c30ff70..f31cca6879 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -1,7 +1,8 @@ -import { readdirSync, readFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { tmpdir } from 'node:os' import { describe, expect, it } from 'vitest' -import { collectPythonDependencies, isPermissive, type Manifest, manifestPatterns, parsePyprojectRequirements, parseVendoredRows, render, tierExternalDeps } from './gen-third-party-notices.ts' +import { collectPythonDependencies, isPermissive, type Manifest, manifestPatterns, parsePyprojectRequirements, parseVendoredRows, render, tierExternalDeps, virtualManifest } from './gen-third-party-notices.ts' const root = resolve(import.meta.dirname, '..') @@ -63,6 +64,56 @@ describe('tierExternalDeps', () => { }) }) +describe('virtualManifest', () => { + it('resolves a manifest from an ordinary prefix-matching store directory', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-notices-prefix-')) + try { + const name = '@scope/pkg' + const version = '1.0.0' + const store = join(root, 'store') + const manifestDir = join(store, `${name.replace('/', '+')}@${version}`, 'node_modules', name) + mkdirSync(manifestDir, { recursive: true }) + writeFileSync(join(manifestDir, 'package.json'), JSON.stringify({ name, version, license: 'MIT' })) + + expect(virtualManifest(store, name)).toMatchObject({ name, version, license: 'MIT' }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('falls back to a content scan when pnpm 11 truncates the store directory name', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-notices-truncated-')) + try { + const name = '@scope/pkg' + const version = '2.0.0' + const store = join(root, 'store') + // The truncated name no longer starts with `@scope+pkg@`, so only the + // whole-store content scan can find the package. + const manifestDir = join(store, '@scope+pkg_9f1c2d3e4a5b6c7d8e9f0a1b2c3d4e5f', 'node_modules', name) + mkdirSync(manifestDir, { recursive: true }) + writeFileSync(join(manifestDir, 'package.json'), JSON.stringify({ name, version, license: 'Apache-2.0' })) + + expect(virtualManifest(store, name)).toMatchObject({ name, version, license: 'Apache-2.0' }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('returns undefined when neither the prefix nor the content scan finds the package', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-notices-miss-')) + try { + const store = join(root, 'store') + const other = join(store, 'other-pkg@1.0.0', 'node_modules', 'other-pkg') + mkdirSync(other, { recursive: true }) + writeFileSync(join(other, 'package.json'), JSON.stringify({ name: 'other-pkg', version: '1.0.0' })) + + expect(virtualManifest(store, '@scope/missing')).toBeUndefined() + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +}) + describe('parseVendoredRows', () => { it('reads the committed vendor manifest table', () => { const rows = parseVendoredRows(readFileSync(resolve(root, 'vendor/README.md'), 'utf8')) diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index a4a1f4c343..6b790829d5 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -172,8 +172,13 @@ type VirtualManifest = Manifest & { license?: string; repository?: string | { ur * long names (a peer-suffixed name past the length limit becomes * `_`), so a content scan falls back over the whole store when * the prefix misses. + * + * @param virtual - the `.pnpm` virtual store directory to scan. + * @param name - the external package name, exactly as `node_modules` spells it. + * @returns the parsed manifest, or `undefined` when neither the prefix match + * nor the content scan finds the package's `package.json`. */ -function virtualManifest(virtual: string, name: string): VirtualManifest | undefined { +export function virtualManifest(virtual: string, name: string): VirtualManifest | undefined { const prefix = `${name.replace('/', '+')}@` const entry = readdirSync(virtual).find(dir => dir.startsWith(prefix)) if (entry !== undefined) { From 3c6583370f994f9e5454a2f71c40d0b09d72fd4b Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 11:25:51 +0800 Subject: [PATCH 27/52] fix(fs-search): platform-normalize sampling test paths The sampler and the workdir-relative display conversion group by node:path.sep, so the POSIX-style '/' literals in the cross-directory sampling cases collapse into per-path groups on Windows (every path its own top-level entry), turning the round-robin sample into a head. The same gap exists on master (its platform-separator fix predates these tests); normalize the literals through a platform helper instead, and keep the POSIX-backslash-as-filename case Windows-skipped as before. --- .../fs/tool-fs-search/tests/tools.spec.ts | 65 ++++++++++--------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index a8a2498c60..9a8fa991ff 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -40,6 +40,13 @@ import { const testToolSignal = new AbortController().signal +/** + * Normalize a POSIX-style test path to the platform separator: the sampler and + * the workdir-relative display conversion group by `node:path.sep`, so + * `/`-literal paths would collapse into per-path groups on Windows. + */ +const w = (path: string): string => path.replaceAll('/', sep) + /** One scripted collect-mode stream, returned by `readFrom(0)` after settlement. */ interface ScriptedStream { text: string @@ -621,24 +628,24 @@ describe('raw output acquisition', () => { describe('cross-directory sampling', () => { it('gives every top-level entry a slot before any entry gets a second', () => { - const paths = ['v/a', 'v/b', 'v/c', 'v/d', 'src/e', 'guide/f'] + const paths = ['v/a', 'v/b', 'v/c', 'v/d', 'src/e', 'guide/f'].map(w) // The head of 3 would be all `v/`; the sample reaches all three entries. - expect(sampleAcrossTopLevel(paths, 3)).toEqual({ items: ['v/a', 'src/e', 'guide/f'], shown: 3, total: 3 }) + expect(sampleAcrossTopLevel(paths, 3)).toEqual({ items: ['v/a', 'src/e', 'guide/f'].map(w), shown: 3, total: 3 }) // Extra slots go round again — to the only entry with paths left — and the // page stays grouped by entry rather than interleaved. - expect(sampleAcrossTopLevel(paths, 5)).toEqual({ items: ['v/a', 'v/b', 'v/c', 'src/e', 'guide/f'], shown: 3, total: 3 }) + expect(sampleAcrossTopLevel(paths, 5)).toEqual({ items: ['v/a', 'v/b', 'v/c', 'src/e', 'guide/f'].map(w), shown: 3, total: 3 }) }) it('hands an exhausted entry the remaining slots go to entries that still have paths', () => { - const paths = ['solo/a', 'many/b', 'many/c', 'many/d'] - expect(sampleAcrossTopLevel(paths, 3)).toEqual({ items: ['solo/a', 'many/b', 'many/c'], shown: 2, total: 2 }) + const paths = ['solo/a', 'many/b', 'many/c', 'many/d'].map(w) + expect(sampleAcrossTopLevel(paths, 3)).toEqual({ items: ['solo/a', 'many/b', 'many/c'].map(w), shown: 2, total: 2 }) }) it('does not rescan exhausted entries while filling a skewed page', () => { const singletonCount = 12_500 const paths = [ - ...Array.from({ length: singletonCount }, (_, index) => `group-${index}/only`), - ...Array.from({ length: singletonCount }, (_, index) => `late/${index}`), + ...Array.from({ length: singletonCount }, (_, index) => `group-${index}${sep}only`), + ...Array.from({ length: singletonCount }, (_, index) => `late${sep}${index}`), ] expect(sampleAcrossTopLevel(paths, paths.length - 1)).toMatchObject({ shown: singletonCount + 1, @@ -648,15 +655,15 @@ describe('cross-directory sampling', () => { }, 500) it('reports the entries it could not reach when the page is smaller than the top level', () => { - const paths = ['a/1', 'b/1', 'c/1', 'd/1'] - expect(sampleAcrossTopLevel(paths, 2)).toEqual({ items: ['a/1', 'b/1'], shown: 2, total: 4 }) + const paths = ['a/1', 'b/1', 'c/1', 'd/1'].map(w) + expect(sampleAcrossTopLevel(paths, 2)).toEqual({ items: ['a/1', 'b/1'].map(w), shown: 2, total: 4 }) }) it('groups an absolute path by its first real name, not by its empty root segment', () => { // Paths outside the workdir stay absolute; without stripping the leading // separator every one of them would collapse into a single empty group. - expect(sampleAcrossTopLevel(['/out/a', '/out/b', '/away/c', '/away/d'], 2)) - .toEqual({ items: ['/out/a', '/away/c'], shown: 2, total: 2 }) + expect(sampleAcrossTopLevel(['/out/a', '/out/b', '/away/c', '/away/d'].map(w), 2)) + .toEqual({ items: ['/out/a', '/away/c'].map(w), shown: 2, total: 2 }) }) it('reproduces the modification-time-ordered head for a flat result', () => { @@ -669,15 +676,15 @@ describe('cross-directory sampling', () => { 'workspace/vendor/b.ts', 'workspace/source/c.ts', 'workspace/guides/d.md', - ], 3, 'workspace')).toEqual({ - items: ['workspace/vendor/a.ts', 'workspace/source/c.ts', 'workspace/guides/d.md'], + ].map(w), 3, 'workspace')).toEqual({ + items: ['workspace/vendor/a.ts', 'workspace/source/c.ts', 'workspace/guides/d.md'].map(w), shown: 3, total: 3, }) - expect(sampleAcrossTopLevel(['./vendor/a.ts', './src/b.ts'], 2, '.')) - .toEqual({ items: ['./vendor/a.ts', './src/b.ts'], shown: 2, total: 2 }) - expect(sampleAcrossTopLevel(['/vendor/a.ts', '/src/b.ts'], 2, '/')) - .toEqual({ items: ['/vendor/a.ts', '/src/b.ts'], shown: 2, total: 2 }) + expect(sampleAcrossTopLevel(['./vendor/a.ts', './src/b.ts'].map(w), 2, '.')) + .toEqual({ items: ['./vendor/a.ts', './src/b.ts'].map(w), shown: 2, total: 2 }) + expect(sampleAcrossTopLevel(['/vendor/a.ts', '/src/b.ts'].map(w), 2, w('/'))) + .toEqual({ items: ['/vendor/a.ts', '/src/b.ts'].map(w), shown: 2, total: 2 }) const rooted = [ ['root', 'a', 'one'].join(sep), ['root', 'a', 'two'].join(sep), @@ -685,8 +692,8 @@ describe('cross-directory sampling', () => { ] expect(sampleAcrossTopLevel(rooted, 2, 'root')) .toEqual({ items: [rooted[0], rooted[2]], shown: 2, total: 2 }) - expect(sampleAcrossTopLevel(['other/a.ts'], 1, 'src')) - .toEqual({ items: ['other/a.ts'], shown: 1, total: 1 }) + expect(sampleAcrossTopLevel(['other/a.ts'].map(w), 1, 'src')) + .toEqual({ items: ['other/a.ts'].map(w), shown: 1, total: 1 }) expect(sampleAcrossTopLevel(['src'], 1, 'src')) .toEqual({ items: ['src'], shown: 1, total: 1 }) }) @@ -767,9 +774,9 @@ describe('glob results', () => { // freshly-unpacked subtree first, and a head-of-3 reads like the entire // workspace. The sample reaches every top-level entry instead. const { ctx, subprocess } = await setup({ config: { globMaxResults: 3 } }) - subprocess.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts', 'guide/e.md', 'top.txt'].join('\n')) + subprocess.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts', 'guide/e.md', 'top.txt'].map(w).join('\n')) const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') }) - expect(text(result)).toBe('vendor/a.ts\nsrc/d.ts\nguide/e.md\n\n' + expect(text(result)).toBe(['vendor/a.ts', 'src/d.ts', 'guide/e.md'].map(w).join('\n') + '\n\n' + '(Showing 3 of 6 paths, sampled across 3 of the 4 top-level entries this pattern matched ' + 'instead of taken in modification-time order. Narrow path to inspect a specific subtree. ' + 'The complete result could not be saved; narrow pattern or path to see more.)') @@ -792,9 +799,9 @@ describe('glob results', () => { 'workspace/vendor/b.ts', 'workspace/source/c.ts', 'workspace/guides/d.md', - ].join('\n')) - const result = await call(ctx, 'glob', { pattern: '*', path: 'workspace' }, { agent: agent('/w') }) - expect(text(result)).toContain('workspace/vendor/a.ts\nworkspace/source/c.ts\nworkspace/guides/d.md') + ].map(w).join('\n')) + const result = await call(ctx, 'glob', { pattern: '*', path: w('workspace') }, { agent: agent('/w') }) + expect(text(result)).toContain(['workspace/vendor/a.ts', 'workspace/source/c.ts', 'workspace/guides/d.md'].map(w).join('\n')) expect(text(result)).toContain('sampled across 3 of the 3 top-level entries') }) @@ -805,17 +812,17 @@ describe('glob results', () => { '/w/workspace/vendor/b.ts', '/w/workspace/source/c.ts', '/w/workspace/guides/d.md', - ].join('\n')) - const result = await call(ctx, 'glob', { pattern: '*', path: '/w/workspace' }, { agent: agent('/w') }) - expect(text(result)).toContain('workspace/vendor/a.ts\nworkspace/source/c.ts\nworkspace/guides/d.md') + ].map(w).join('\n')) + const result = await call(ctx, 'glob', { pattern: '*', path: w('/w/workspace') }, { agent: agent(w('/w')) }) + expect(text(result)).toContain(['workspace/vendor/a.ts', 'workspace/source/c.ts', 'workspace/guides/d.md'].map(w).join('\n')) expect(text(result)).toContain('sampled across 3 of the 3 top-level entries') }) it('drops the narrowing hint when the sample reaches every top-level entry', async () => { const { ctx, subprocess } = await setup({ config: { globMaxResults: 3 } }) - subprocess.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts'].join('\n')) + subprocess.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts'].map(w).join('\n')) expect(text(await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') }))) - .toBe('vendor/a.ts\nvendor/b.ts\nsrc/d.ts\n\n' + .toBe(['vendor/a.ts', 'vendor/b.ts', 'src/d.ts'].map(w).join('\n') + '\n\n' + '(Showing 3 of 4 paths, sampled across 2 of the 2 top-level entries this pattern matched ' + 'instead of taken in modification-time order. ' + 'The complete result could not be saved; narrow pattern or path to see more.)') From 998b80886b94504cc53f8230713c35d274f83ccd Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 11:26:06 +0800 Subject: [PATCH 28/52] fix(tool-str-replace-editor): platform-normalize listing assertion paths The view listing carries absolute display paths, so the POSIX-style 'node_modules_old/kept.js' substring assertions only match on Linux (Windows display paths use backslashes). Assert with platform separators to keep the same check meaningful on Windows; the pre-existing gap is identical on master. --- packages/fs/tool-str-replace-editor/tests/tools.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 797263897f..4fda9a2d3f 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -244,8 +244,10 @@ describe('tool-str-replace-editor', () => { expect(listing).not.toContain('too-deep.txt') expect(listing).not.toContain('index.js') expect(listing).not.toContain('module.pyc') - expect(listing).toContain('node_modules_old/kept.js') - expect(listing).toContain('__pycache__backup/kept.py') + // The listing carries absolute display paths; the POSIX-style substrings + // only match on Linux, so assert with platform separators. + expect(listing).toContain(join('node_modules_old', 'kept.js')) + expect(listing).toContain(join('__pycache__backup', 'kept.py')) const clipped = await setup({ maxOutputChars: 10 }) await writeFile(join(clipped.root, 'large.txt'), 'x'.repeat(100)) From 5ee544081ade777d4d72b2fd59f3d4c51cfb1ba3 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:56:31 -0700 Subject: [PATCH 29/52] fix(web): unblock remote welcome onboarding --- ...versioned-gui-welcome-onboarding.i18n.yaml | 4 +- ...-07-30-versioned-gui-welcome-onboarding.md | 8 +-- ...-30-versioned-gui-welcome-onboarding.zh.md | 8 +-- apps/web/tests/remote-welcome.e2e.ts | 53 +++++++++++++++++++ apps/web/tests/scaffold.ts | 15 ++++-- apps/web/tsconfig.json | 1 + .../ui-settings-general/README.i18n.yaml | 4 +- packages/client/ui-settings-general/README.md | 2 +- .../client/ui-settings-general/README.zh.md | 2 +- .../ui-settings-general/src/client/index.ts | 14 ++++- .../src/client/welcome-store.ts | 34 +++++++++--- .../ui-settings-general/tests/apply.spec.ts | 18 ++++++- .../tests/welcome-store.spec.ts | 15 ++++++ tsconfig.host.json | 1 + 14 files changed, 152 insertions(+), 27 deletions(-) create mode 100644 apps/web/tests/remote-welcome.e2e.ts diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml index 579586f30a..6a109260c1 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md -2026-07-30-versioned-gui-welcome-onboarding.md: 8155838f3b6b50f3474ef6c30065ad0d79e6f8af -2026-07-30-versioned-gui-welcome-onboarding.zh.md: c221a6d663af60b03757f135045961bcbcdd0da7 +2026-07-30-versioned-gui-welcome-onboarding.md: 1199cec532dd23930c70b236e6fcac80832204ff +2026-07-30-versioned-gui-welcome-onboarding.zh.md: d59e8971689fad1836127fea79cd392d3d5787c3 diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md index 8155838f3b..1199cec532 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md @@ -14,15 +14,15 @@ The GUI's credential onboarding begins with a DeepSeek-specific readiness check, **Ownerless product onboarding belongs to `ui-settings-general`.** `src/onboarding-copy.ts` is the single editable source for the complete notice, the Continue label, and `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese owner copy. Runtime locale dictionaries derive their welcome values from that file, and tests import the same owner instead of repeating paragraph text. The notice is browser UI only: it creates no Session event and contributes no model-visible content. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out. -**Acknowledgement is durable per Harness profile.** The Host half registers a `ui-onboarding` section in the user-settings seam, stored under the active `$DSH_HOME/settings.yaml`. The browser shows the notice unless `welcomeNoticeVersion` equals the owner constant exactly. Continue applies one path mutation with the current version and calls `complete()` only after the Host commits it; a failed write leaves the notice open, and closing the page or process writes nothing. Bumping the constant intentionally makes every profile acknowledge the revised copy once. +**Loopback acknowledgement is durable per Harness profile.** The Host half registers a `ui-onboarding` section in the user-settings seam, stored under the active `$DSH_HOME/settings.yaml`. A loopback browser shows the notice unless `welcomeNoticeVersion` equals the owner constant exactly. Continue applies one path mutation with the current version and calls `complete()` only after the Host commits it; a failed write leaves the notice open, and closing the page or process writes nothing. Bumping the constant intentionally makes every profile acknowledge the revised copy once. A non-loopback browser must not call the loopback-only settings API. It presents the same notice, but explicit Continue completes the step only in the current browser process; reload or a new process presents it again. -**Concurrent views converge without stale replacement.** The acknowledgement write omits `expectedRevision` deliberately: every tab writes the same version to one path, so the operation is idempotent and preserves sibling fields instead of rebuilding the section. `settings/document-updated` becomes `host/settings-changed`; an already mounted tab refetches and advances when another tab or an external editor commits the current version. The API proxy exposes this one product namespace through a closed allowlist beside configurable-provider namespaces, without treating its changes as model-catalog invalidations. +**Concurrent loopback views converge without stale replacement.** The acknowledgement write omits `expectedRevision` deliberately: every loopback tab writes the same version to one path, so the operation is idempotent and preserves sibling fields instead of rebuilding the section. `settings/document-updated` becomes `host/settings-changed`; an already mounted loopback tab refetches and advances when another tab or an external editor commits the current version. The API proxy exposes this one product namespace through a closed allowlist beside configurable-provider namespaces, without treating its changes as model-catalog invalidations. **Onboarding temporarily owns the viewport as one continuous stage.** A solid product surface replaces the complete application view through a body-level portal and marks the underlying app root inert; the exact required mask remains mounted behind that surface with `position:absolute`, zero left/right/bottom offsets, `top:80px`, `rgba(0, 0, 0, 0.24)`, and `backdrop-filter: blur(2px)`. Welcome and conditional credential setup render as successive pages in this stage instead of independent modals. Both pages reuse the Web UI's black `BrandWordmark`. The welcome page preserves the four authored paragraphs verbatim under the `内测声明` title; every paragraph uses one 16/28 body scale, and only the requested action clause inside the final paragraph receives a subtle 500 weight. A short staggered opacity/vertical entrance supplies pacing without blocking interaction and disappears under reduced motion. The title receives initial focus, Continue is the sole button, and no close, Escape, or mask-click path exists. ## Alternatives considered -**Browser local storage** — rejected because acknowledgement would follow one browser profile rather than `$DSH_HOME`; a fresh Harness profile could incorrectly inherit a prior acknowledgement, and external profile edits would have no authoritative update stream. +**Browser local storage** — rejected because acknowledgement would follow one browser profile rather than `$DSH_HOME`; a fresh Harness profile could incorrectly inherit a prior acknowledgement, and external profile edits would have no authoritative update stream. Non-loopback fallback therefore remains process-local rather than browser-profile-local. **A second independent modal in `ui-settings-general`** — rejected because list registrants would still stack whenever welcome and credential readiness were both true. Ordered ownership belongs to the shell that declares and renders the list. @@ -32,4 +32,4 @@ The GUI's credential onboarding begins with a DeepSeek-specific readiness check, ## Consequences -A fresh profile always sees the welcome notice before provider-specific onboarding; an already configured credential skips only the later DeepSeek step. Reloading after Continue stays past the acknowledged version, changing the owner version presents it again, and closing before Continue leaves the next launch unchanged. Focused store and React tests pin exact-version comparison, write failure, sole-action behavior, no-dismiss paths, coordinator ordering, conditional DeepSeek transfer, and HMR cleanup. The real Chromium scenario boots the shipped Web composition with an isolated harness home, verifies the exact mask geometry and computed styles, reloads before and after acknowledgement, continues into missing-credential setup, confirms an acknowledged-version mismatch returns while the credential is configured, and checks the browser console. +A fresh profile always sees the welcome notice before provider-specific onboarding; an already configured credential skips only the later DeepSeek step. On loopback, reloading after Continue stays past the acknowledged version, changing the owner version presents it again, and closing before Continue leaves the next launch unchanged. On non-loopback, Continue advances the live process without a privileged settings request and reload presents the notice again. Focused store and React tests pin both persistence modes, exact-version comparison, write failure, sole-action behavior, no-dismiss paths, coordinator ordering, conditional DeepSeek transfer, and HMR cleanup. The real Chromium scenario boots the shipped Web composition with an isolated harness home, verifies the exact mask geometry and computed styles, reloads before and after acknowledgement, continues into missing-credential setup, confirms an acknowledged-version mismatch returns while the credential is configured, and checks the browser console. diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md index c221a6d663..d59e897168 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md @@ -14,15 +14,15 @@ GUI 的凭据引导从 DeepSeek 专用的就绪状态检查开始,但内部测 **不属于单一功能的产品引导由 `ui-settings-general` 持有。** `src/onboarding-copy.ts` 是完整通知、「继续」按钮文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文所有者文案。运行时 locale 字典从该文件派生欢迎文案,测试也导入同一个所有者,而不重复段落文本。该通知只存在于浏览器 UI:它不会创建会话事件,也不会贡献任何模型可见内容。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。 -**确认状态按 Harness profile 持久化。** 宿主端在 user-settings seam 中注册 `ui-onboarding` 分节,并存入当前 `$DSH_HOME/settings.yaml`。除非 `welcomeNoticeVersion` 与文案所有者文件中的常量精确相等,否则浏览器会显示通知。「继续」会以当前版本执行一次路径变更,并且仅在宿主端提交成功后调用 `complete()`;写入失败时通知保持打开,关闭页面或进程则不会写入任何内容。提升该常量会有意要求每个 profile 对修订后的文案重新确认一次。 +**loopback 确认状态按 Harness profile 持久化。** 宿主端在 user-settings seam 中注册 `ui-onboarding` 分节,并存入当前 `$DSH_HOME/settings.yaml`。除非 `welcomeNoticeVersion` 与文案所有者文件中的常量精确相等,否则 loopback 浏览器会显示通知。「继续」会以当前版本执行一次路径变更,并且仅在宿主端提交成功后调用 `complete()`;写入失败时通知保持打开,关闭页面或进程则不会写入任何内容。提升该常量会有意要求每个 profile 对修订后的文案重新确认一次。非 loopback 浏览器不能调用仅限 loopback 的 settings API;它仍显示同一通知,但显式点击「继续」只会在当前浏览器进程中完成该步骤,重新加载或新进程会再次显示通知。 -**并发视图无需陈旧的整体替换即可收敛。** 确认写入有意省略 `expectedRevision`:每个标签页都向同一路径写入相同版本,因此该操作是幂等的,并会保留同级字段,而不是重建整个分节。`settings/document-updated` 会转为 `host/settings-changed`;另一个标签页或外部编辑器提交当前版本后,已挂载的标签页会重新拉取状态并推进。API 网关在可配置提供方 namespace 之外,通过封闭的允许列表暴露这一个产品 namespace,同时不会把它的变更视为模型目录失效事件。 +**并发 loopback 视图无需陈旧的整体替换即可收敛。** 确认写入有意省略 `expectedRevision`:每个 loopback 标签页都向同一路径写入相同版本,因此该操作是幂等的,并会保留同级字段,而不是重建整个分节。`settings/document-updated` 会转为 `host/settings-changed`;另一个标签页或外部编辑器提交当前版本后,已挂载的 loopback 标签页会重新拉取状态并推进。API 网关在可配置提供方 namespace 之外,通过封闭的允许列表暴露这一个产品 namespace,同时不会把它的变更视为模型目录失效事件。 **引导流程会暂时接管视口,形成一个连续阶段。** 纯色产品界面通过挂载到 `body` 的 portal 取代完整的应用视图,并将底层应用根节点标记为 inert;严格符合要求的遮罩仍挂载在该界面后方,并保留 `position:absolute`、left/right/bottom 偏移量为零、`top:80px`、`rgba(0, 0, 0, 0.24)` 和 `backdrop-filter: blur(2px)`。欢迎页和按条件显示的凭据设置页在这一阶段中依次呈现,而不是各自作为独立的模态窗口。两个页面都复用 Web UI 的黑色 `BrandWordmark`。欢迎页在 `内测声明` 标题下逐字保留既定的四段文案;所有段落统一采用 16/28 的正文字号与行高,只有最后一段中指定的行动语句使用较为克制的 500 字重。短暂的错落式透明度与纵向位移动画营造出舒缓节奏,但不会阻碍交互,并会在用户启用减少动态效果时禁用。初始焦点落在标题上,「继续」是唯一按钮,且不存在关闭、Escape 或点击遮罩的退出路径。 ## 曾考虑的替代方案 -**浏览器本地存储**:不予采用,因为确认状态会跟随某个浏览器 profile,而不是 `$DSH_HOME`;全新的 Harness profile 可能错误继承此前的确认状态,外部 profile 编辑也没有权威更新流。 +**浏览器本地存储**:不予采用,因为确认状态会跟随某个浏览器 profile,而不是 `$DSH_HOME`;全新的 Harness profile 可能错误继承此前的确认状态,外部 profile 编辑也没有权威更新流。因此,非 loopback 的回退保持为进程内状态,而不是浏览器 profile 状态。 **在 `ui-settings-general` 中再增加一个独立模态窗口**:不予采用,因为欢迎通知和凭据就绪状态同时为真时,list 注册方仍会堆叠。声明并渲染该 list 的外壳应当持有有序所有权。 @@ -32,4 +32,4 @@ GUI 的凭据引导从 DeepSeek 专用的就绪状态检查开始,但内部测 ## 后果 -全新 profile 始终会在提供方专用引导之前看到欢迎通知;凭据已经配置时,只会跳过后续 DeepSeek 步骤。点击「继续」后重新加载不会再次显示已确认版本,更改文案所有者文件中的版本值会让通知重新出现,而确认前关闭窗口不会改变下次启动。针对性的 store 与 React 测试固化了精确版本比较、写入失败、单一操作、不可关闭路径、协调器顺序、按条件移交 DeepSeek 步骤和 HMR(热模块替换)清理行为。真实 Chromium 场景会使用隔离的 harness 家目录启动随产品提供的 Web 组合,验证遮罩的精确几何尺寸和计算样式,在确认前后分别重新加载,继续进入凭据缺失设置流程,确认凭据已配置时确认版本不匹配仍会使通知重新出现,并检查浏览器控制台。 +全新 profile 始终会在提供方专用引导之前看到欢迎通知;凭据已经配置时,只会跳过后续 DeepSeek 步骤。在 loopback 上,点击「继续」后重新加载不会再次显示已确认版本,更改文案所有者文件中的版本值会让通知重新出现,而确认前关闭窗口不会改变下次启动。在非 loopback 上,「继续」会在不发起受保护 settings 请求的情况下推进当前进程,重新加载则再次显示通知。针对性的 store 与 React 测试固化了两种持久化模式、精确版本比较、写入失败、单一操作、不可关闭路径、协调器顺序、按条件移交 DeepSeek 步骤和 HMR(热模块替换)清理行为。真实 Chromium 场景会使用隔离的 harness 家目录启动随产品提供的 Web 组合,验证遮罩的精确几何尺寸和计算样式,在确认前后分别重新加载,继续进入凭据缺失设置流程,确认凭据已配置时确认版本不匹配仍会使通知重新出现,并检查浏览器控制台。 diff --git a/apps/web/tests/remote-welcome.e2e.ts b/apps/web/tests/remote-welcome.e2e.ts new file mode 100644 index 0000000000..483d6c369c --- /dev/null +++ b/apps/web/tests/remote-welcome.e2e.ts @@ -0,0 +1,53 @@ +// Trusted non-loopback Web access must not wedge on the loopback-only +// settings API while the mandatory product notice owns the viewport. +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { ZH_BROWSER_LOCALE } from './support.ts' +import { WELCOME_NOTICE_COPY } from '@deepseek-ai/dsh-client-ui-settings-general' + +const MODE = webSnapshotMode() + +describe.skipIf(MODE === 'record')('web e2e: remote welcome notice', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ remoteAuthority: 'remote.localhost', welcomeNoticePending: true }) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('#root', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('advances process-locally and presents the notice again after reload', async () => { + const welcome = page.getByRole('region', { name: WELCOME_NOTICE_COPY.zh.title }) + await welcome.waitFor({ timeout: 15_000 }) + expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(true) + + await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click() + await welcome.waitFor({ state: 'detached', timeout: 15_000 }) + await expect.poll( + () => page.locator('#root').evaluate(root => (root as HTMLElement).inert), + { timeout: 15_000 }, + ).toBe(false) + const reloadWarnings = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + acknowledgeReloadConnectionLoss(tripwire, reloadWarnings) + await welcome.waitFor({ timeout: 15_000 }) + expect(tripwire.warnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 1b8afbb247..62a1d31d0d 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -89,7 +89,7 @@ const REPLAY_PROVIDERS = [{ export interface WebScaffold { /** The active snapshot mode this scaffold booted under. */ mode: WebSnapshotMode - /** Browser-facing origin (http://127.0.0.1:). */ + /** Browser-facing origin for the bound test server. */ baseUrl: string /** Settled root context (the in-process barrier seam; headless event subscription is its sanctioned use). */ ctx: Context @@ -166,6 +166,8 @@ export interface LaunchOptions { } /** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */ welcomeNoticePending?: boolean + /** Browse through this trusted non-loopback hostname while the test server stays bound to loopback. */ + remoteAuthority?: string } /** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */ @@ -185,6 +187,7 @@ async function cleanupScaffoldWorld(ctx: Context, workspaceCwd: string, persiste export async function launchWebScaffold(options: LaunchOptions = {}): Promise { requireDist() const mode = webSnapshotMode() + const browserHost = options.remoteAuthority ?? '127.0.0.1' if (mode === 'record') { // Both owning vitest configs (web unconditionally, snapshot in record // mode) load the repo-root .env before this file runs. @@ -261,7 +264,13 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise /^\d{1,3}$/.test(part) && Number(part) <= 255) +} + +function welcomePersistence(): 'host' | 'memory' { + return typeof location === 'undefined' || isLoopbackHostname(location.hostname) ? 'host' : 'memory' +} + /** * Required services (cordis fiber inject). The target slots are declared by * ui-settings' apply, whose activation order relative to this one is NOT @@ -61,7 +73,7 @@ export function apply(ctx: ClientContext): void { // locale/change re-registration wiring. const t = ctx.locale.bind(NS) const connection = ctx.get('connection') as ConnectionHandle - const welcomeController = new WelcomeNoticeStore(connection.api) + const welcomeController = new WelcomeNoticeStore(connection.api, welcomePersistence()) const useWelcomeSnapshot = bindSnapshotSelector(welcomeController.store) const welcomeInjected = (): WelcomeNoticeInjected => ({ controller: welcomeController, diff --git a/packages/client/ui-settings-general/src/client/welcome-store.ts b/packages/client/ui-settings-general/src/client/welcome-store.ts index ad0e18305c..fdff28f052 100644 --- a/packages/client/ui-settings-general/src/client/welcome-store.ts +++ b/packages/client/ui-settings-general/src/client/welcome-store.ts @@ -1,4 +1,4 @@ -/** Durable welcome-notice state over the Host settings document. */ +/** Welcome-notice state, durable when the browser may use Host settings. */ import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' @@ -24,7 +24,7 @@ function acknowledgementOf(view: SettingsNamespaceView): string | undefined { return typeof value === 'string' ? value : undefined } -/** Coordinates welcome acknowledgement reads and the sole durable write. */ +/** Coordinates durable Host acknowledgement or a process-local remote fallback. */ export class WelcomeNoticeStore { /** uSES-safe state source shared by the registered welcome step. */ readonly store: SnapshotStore = createSnapshotStore({ @@ -33,12 +33,22 @@ export class WelcomeNoticeStore { private generation = 0 - /** @param api - settings wire face used for durable reads and writes. */ - constructor(private readonly api: Pick) {} + /** + * @param api - settings wire face used for durable reads and writes. + * @param persistence - remote browsers use memory because settings is loopback-only. + */ + constructor( + private readonly api: Pick, + private readonly persistence: 'host' | 'memory' = 'host', + ) {} - /** Load the current acknowledgement from the Host settings document. */ + /** Load the acknowledgement from Host settings or initialize process-local state. */ async load(): Promise { const generation = ++this.generation + if (this.persistence === 'memory') { + this.store.update((state) => { state.status = 'ready'; state.error = null }) + return + } this.store.update((state) => { state.status = 'loading'; state.error = null }) try { const response = await this.api.settings.describe({}) @@ -64,12 +74,20 @@ export class WelcomeNoticeStore { } /** - * Persist this copy version. The path mutation is idempotent across tabs and - * preserves every sibling setting; failure leaves the step unacknowledged. - * @returns true only when the Host committed the acknowledgement. + * Acknowledge this copy version. The Host path mutation is idempotent across + * tabs and preserves sibling settings; remote fallback changes only this store. + * @returns true when the selected persistence mode accepted the acknowledgement. */ async acknowledge(): Promise { const generation = ++this.generation + if (this.persistence === 'memory') { + this.store.update((state) => { + state.status = 'ready' + state.acknowledged = true + state.error = null + }) + return true + } this.store.update((state) => { state.status = 'saving'; state.error = null }) try { const response = await this.api.settings.mutate({ diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.spec.ts index 81a56a9a4a..17e4ced2b2 100644 --- a/packages/client/ui-settings-general/tests/apply.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.spec.ts @@ -1,6 +1,6 @@ /** Ownerless-copy registrations: the four seats, the dictionaries, thunked labels, and HMR recovery. */ import { Context } from 'cordis' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' @@ -16,6 +16,8 @@ import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts' // the shipped Chinese copy, so they state the browser they assume. usePinnedBrowserLanguages('zh-CN') +afterEach(() => { vi.unstubAllGlobals() }) + /** The five seats this plugin fills (slot name → expected component). */ const SEATS = [ ['settings.trigger', TriggerContent], @@ -159,6 +161,20 @@ describe('ui-settings-general apply', () => { await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(3) }) }) + it('keeps remote welcome acknowledgement process-local', async () => { + vi.stubGlobal('location', { hostname: '192.0.2.20' }) + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const entry = b.slots.entries('settings.onboarding')[0]! + const { controller } = (entry.inject as unknown as () => WelcomeNoticeInjected)() + + await controller.load() + await expect(controller.acknowledge()).resolves.toBe(true) + expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true }) + expect(b.settingsDescribe).not.toHaveBeenCalled() + }) + it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => { const b = await bench() const redeclare = declare(b.slots) diff --git a/packages/client/ui-settings-general/tests/welcome-store.spec.ts b/packages/client/ui-settings-general/tests/welcome-store.spec.ts index 28c7b0509c..45e4ca5590 100644 --- a/packages/client/ui-settings-general/tests/welcome-store.spec.ts +++ b/packages/client/ui-settings-general/tests/welcome-store.spec.ts @@ -30,6 +30,21 @@ function deferred() { } describe('WelcomeNoticeStore', () => { + it('acknowledges in memory without calling loopback-only settings APIs', async () => { + const describe = vi.fn() + const mutate = vi.fn() + const controller = new WelcomeNoticeStore({ settings: { describe, mutate } } as never, 'memory') + + await controller.load() + expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: false, error: null }) + await expect(controller.acknowledge()).resolves.toBe(true) + expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null }) + await controller.load() + expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null }) + expect(describe).not.toHaveBeenCalled() + expect(mutate).not.toHaveBeenCalled() + }) + it('acknowledges only the exact current copy version', async () => { for (const [version, acknowledged] of [ [undefined, false], diff --git a/tsconfig.host.json b/tsconfig.host.json index 6e0860317c..a88257a8ff 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -23,6 +23,7 @@ "apps/web/tests/settings-chrome.e2e.ts", "apps/web/tests/models-settings.e2e.ts", "apps/web/tests/onboarding-deepseek-config.e2e.ts", + "apps/web/tests/remote-welcome.e2e.ts", "apps/web/tests/workspace-management.e2e.ts", "apps/web/tests/replay-round-trip.e2e.ts", "apps/web/tests/hmr-live.e2e.ts", From 7a71ab9a92cbb36f5e5e08fe6629ee245b84ac45 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:24:49 -0700 Subject: [PATCH 30/52] fix(web): share loopback hostname policy --- .../client/connection/src/api-request-trust.ts | 9 +-------- packages/client/connection/src/client/index.ts | 2 ++ .../client/connection/src/loopback-hostname.ts | 12 ++++++++++++ .../connection/tests/loopback-hostname.spec.ts | 18 ++++++++++++++++++ .../ui-settings-general/src/client/index.ts | 10 +--------- 5 files changed, 34 insertions(+), 17 deletions(-) create mode 100644 packages/client/connection/src/loopback-hostname.ts create mode 100644 packages/client/connection/tests/loopback-hostname.spec.ts diff --git a/packages/client/connection/src/api-request-trust.ts b/packages/client/connection/src/api-request-trust.ts index 8c1bddd631..ecb180dca7 100644 --- a/packages/client/connection/src/api-request-trust.ts +++ b/packages/client/connection/src/api-request-trust.ts @@ -14,6 +14,7 @@ */ import type { IncomingHttpHeaders } from 'node:http' +import { isLoopbackHostname } from './loopback-hostname.ts' /** The request facts the fence reads (structural subset of IncomingMessage). */ interface ApiTrustRequest { @@ -25,14 +26,6 @@ function header(headers: IncomingHttpHeaders, name: string): string | undefined return typeof value === 'string' ? value : undefined } -function isLoopbackHostname(hostname: string): boolean { - if (hostname === 'localhost' || hostname === '[::1]') return true - const parts = hostname.split('.') - return parts.length === 4 - && parts[0] === '127' - && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255) -} - /** Normalized URL of a Host-header authority (hostname lowercased, default port stripped, IPv6 bracketed), or undefined when unparsable. */ function parseAuthority(authority: string): URL | undefined { try { diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index a7ebfbbd86..daa20f1672 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -9,6 +9,8 @@ import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type import { FixtureApiClient } from './fixture.ts' import { WebApiClient } from './web-api-client.ts' +export { isLoopbackHostname } from '../loopback-hostname.ts' + // ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- export type { ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, diff --git a/packages/client/connection/src/loopback-hostname.ts b/packages/client/connection/src/loopback-hostname.ts new file mode 100644 index 0000000000..8fd30445bd --- /dev/null +++ b/packages/client/connection/src/loopback-hostname.ts @@ -0,0 +1,12 @@ +/** + * Whether a normalized URL hostname names the local loopback authority. + * @param hostname - WHATWG URL hostname (IPv6 literals retain brackets). + * @returns true for localhost, IPv6 loopback, or any IPv4 address in 127/8. + */ +export function isLoopbackHostname(hostname: string): boolean { + if (hostname === 'localhost' || hostname === '[::1]') return true + const parts = hostname.split('.') + return parts.length === 4 + && parts[0] === '127' + && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255) +} diff --git a/packages/client/connection/tests/loopback-hostname.spec.ts b/packages/client/connection/tests/loopback-hostname.spec.ts new file mode 100644 index 0000000000..d0eaf3e0c4 --- /dev/null +++ b/packages/client/connection/tests/loopback-hostname.spec.ts @@ -0,0 +1,18 @@ +/** Shared loopback-hostname semantics for the Host fence and browser UI. */ + +import { describe, expect, it } from 'vitest' +import { isLoopbackHostname } from '../src/loopback-hostname.ts' + +describe('isLoopbackHostname', () => { + it('accepts localhost, IPv6 loopback, and the whole IPv4 127/8 block', () => { + for (const hostname of ['localhost', '[::1]', '127.0.0.1', '127.8.9.10', '127.255.255.255']) { + expect(isLoopbackHostname(hostname)).toBe(true) + } + }) + + it('refuses malformed and non-loopback hostnames', () => { + for (const hostname of ['remote.localhost', '::1', '128.0.0.1', '127.0.0', '127.0.0.256', '127.0.0.-1']) { + expect(isLoopbackHostname(hostname)).toBe(false) + } + }) +}) diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index f2449391ce..c0c0fb569f 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -7,7 +7,7 @@ */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import { isLoopbackHostname, type ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' // Type-only: pulls the shell's SlotMap merges (trigger/header/section/item). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' @@ -41,14 +41,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Dictionary namespace owned by this plugin (shell chrome + General copy). */ const NS = 'settings' -function isLoopbackHostname(hostname: string): boolean { - if (hostname === 'localhost' || hostname === '[::1]') return true - const parts = hostname.split('.') - return parts.length === 4 - && parts[0] === '127' - && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255) -} - function welcomePersistence(): 'host' | 'memory' { return typeof location === 'undefined' || isLoopbackHostname(location.hostname) ? 'host' : 'memory' } From 57343cfeb8eaa3bea4151b4d59a54509a914483a Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:30:53 -0700 Subject: [PATCH 31/52] fix(web): expose loopback policy safely --- packages/client/connection/package.json | 1 + packages/client/connection/src/client/index.ts | 2 -- packages/client/tsdown.client.ts | 2 +- packages/client/ui-settings-general/src/client/index.ts | 3 ++- scripts/client-bundle-purity.spec.ts | 1 + 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index d86b2bdf2a..f2842ec061 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -19,6 +19,7 @@ "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }, + "./loopback-hostname": "./src/loopback-hostname.ts", "./src/*": "./src/*", "./package.json": "./package.json" }, diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index daa20f1672..a7ebfbbd86 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -9,8 +9,6 @@ import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type import { FixtureApiClient } from './fixture.ts' import { WebApiClient } from './web-api-client.ts' -export { isLoopbackHostname } from '../loopback-hostname.ts' - // ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- export type { ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 2ff1856b3d..fad00fe4f0 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -28,7 +28,7 @@ const CSS_VIRTUAL_SUFFIX = '.mjs' * Everything else under @deepseek-ai/* is either a module-table entry * (external) or a leak the purity gate rejects. */ -export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/ +export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:host-apiproxy|session|llm|tools|brand)(?:\/|$)|@deepseek-ai\/dsh-client-connection\/loopback-hostname$)/ /** * Documented TEMPORARY exemption, not a platform module (hence not in diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index c0c0fb569f..6a7510d4ac 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -7,7 +7,8 @@ */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' -import { isLoopbackHostname, type ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import { isLoopbackHostname } from '@deepseek-ai/dsh-client-connection/loopback-hostname' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' // Type-only: pulls the shell's SlotMap merges (trigger/header/section/item). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' diff --git a/scripts/client-bundle-purity.spec.ts b/scripts/client-bundle-purity.spec.ts index d70964bdba..2eef1ba5ef 100644 --- a/scripts/client-bundle-purity.spec.ts +++ b/scripts/client-bundle-purity.spec.ts @@ -53,6 +53,7 @@ describe('client bundle purity gate', () => { expect(resolveId('@deepseek-ai/dsh-host-apiproxy/api')).toBeNull() expect(resolveId('@deepseek-ai/dsh-session/surface')).toBeNull() expect(resolveId('@deepseek-ai/dsh-brand')).toBeNull() + expect(resolveId('@deepseek-ai/dsh-client-connection/loopback-hostname')).toBeNull() }) it('throws on any other @deepseek-ai leak', () => { From a533cb6ce4e4d098b1b5eeec24766c399a3ab9a6 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 3 Aug 2026 15:01:35 +0800 Subject: [PATCH 32/52] fix(ui-trajectory): distinguish overlapping request markers --- .../src/client/TrajectoryTable.module.css | 19 +++++---- .../src/client/TrajectoryTable.tsx | 31 ++++++++++++++ .../client/ui-trajectory/tests/table.spec.tsx | 41 +++++++++++++++++++ 3 files changed, 83 insertions(+), 8 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 0b1cbf8030..40f4791d45 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -100,17 +100,12 @@ } .table tbody tr[data-request-only='true'] td { - height: 1px; + height: 0; padding-top: 0; padding-bottom: 0; border-bottom: 0; } -.table tbody tr[data-request-only='true']:has(+ tr[data-request-only='true']) td { - /* Keep consecutive boundary markers from painting their halos over one another. */ - height: 9px; -} - .table tbody tr[data-request-only='true']:last-child td { /* Retain the lower half of the 16px boundary marker at the table's end. */ height: 9px; @@ -130,10 +125,12 @@ } .requestBoundaryControl { + --request-boundary-base-left: 12px; + position: absolute; z-index: 6; top: -8px; - left: 12px; + left: calc(var(--request-boundary-base-left) + var(--request-boundary-offset, 0px)); width: 16px; height: 16px; padding: 0; @@ -198,6 +195,12 @@ box-shadow: 0 0 0 1.5px var(--dsw-alias-brand-primary-new-colorprimary-new-color); } +.requestBoundaryControl[data-request-status='error']::before, +.requestBoundaryControl[data-request-status='error']:hover::before, +.requestBoundaryControl[data-request-status='error']:focus-visible::before { + background: var(--dsw-alias-state-error-primary); +} + .requestBoundaryControl:hover::after, .requestBoundaryControl:focus-visible::after { opacity: 1; @@ -402,7 +405,7 @@ } .requestBoundaryControl { - left: 6px; + --request-boundary-base-left: 6px; } .kindSlot { diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 7973649cf5..13dfee2606 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -191,6 +191,10 @@ type TrajectorySplitStyle = CSSProperties & { '--trajectory-tool-request-width': string } +type RequestBoundaryStyle = CSSProperties & { + '--request-boundary-offset': string +} + function clampDetailsWidth(width: number, splitWidth: number): number { const maxWidth = Math.max( DETAILS_MIN_WIDTH, @@ -453,6 +457,23 @@ function indexRequestNumbers( return numbers } +function indexRequestBoundaryRuns(records: readonly TableRecord[]): ReadonlyMap { + const indexes = new Map() + let previous: TableRecord | undefined + let runIndex = 0 + for (const record of records) { + if (record.cell.requestOnly !== true) { + previous = record + runIndex = 0 + continue + } + runIndex = previous?.cell.requestOnly === true ? runIndex + 1 : 0 + indexes.set(record.cell.index, runIndex) + previous = record + } + return indexes +} + function summarizeTurn(records: readonly TableRecord[]): string { const steps = new Set( records @@ -1545,6 +1566,7 @@ export function TrajectoryTable({ collapsedAssistants, ) : filterRecords(allRecords, searchMatchIndexes) + const requestBoundaryRuns = indexRequestBoundaryRuns(records) const selected = allRecords.find(record => record.cell.index === selectedIndex) const selectedPrompt = selected?.cell.kind === 'system' ? selected.cell.promptDetail @@ -1791,6 +1813,12 @@ export function TrajectoryTable({ const requestInfo = request === undefined ? undefined : sessionRequestNumbers?.find(candidate => candidate.number === request) + const requestStatus = requestInfo?.status + ?? (record.cell.isError === true ? 'error' : undefined) + const requestRunIndex = requestBoundaryRuns.get(record.cell.index) ?? 0 + const requestBoundaryStyle: RequestBoundaryStyle = { + '--request-boundary-offset': `${requestRunIndex * 8}px`, + } const requestLabel = request === undefined ? undefined : `Request #${request}${requestInfo?.purpose === 'compaction' ? ' · Compaction' : ''}` @@ -1882,6 +1910,9 @@ export function TrajectoryTable({ aria-label={requestLabel} aria-pressed={requestSelected} data-label={requestLabel} + data-request-run-index={requestRunIndex} + data-request-status={requestStatus} + style={requestBoundaryStyle} onClick={(event) => { event.stopPropagation() selectRequest({ diff --git a/packages/client/ui-trajectory/tests/table.spec.tsx b/packages/client/ui-trajectory/tests/table.spec.tsx index 65c3da3255..aaea399a83 100644 --- a/packages/client/ui-trajectory/tests/table.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.spec.tsx @@ -222,6 +222,47 @@ describe('TrajectoryTable', () => { expect(errorResult.closest('[class*="errorPayload"]')).toBeTruthy() }) + it('marks failed requests and lays coincident request markers left to right', () => { + const turns: readonly TrajectoryTurnModel[] = [ + { + turn: null, + groups: [{ + title: 'Step 1', + cells: [{ + index: 1, + kind: 'message', + text: '', + requestOnly: true, + isError: true, + timeSeconds: 0.1, + }], + }], + }, + { + turn: null, + groups: [{ + title: 'Step 2', + cells: [{ + index: 2, + kind: 'message', + text: '', + requestOnly: true, + timeSeconds: 0.1, + }], + }], + }, + ] + render() + + const failed = screen.getByRole('button', { name: 'Request #1' }) + const retry = screen.getByRole('button', { name: 'Request #2' }) + expect(failed.getAttribute('data-request-status')).toBe('error') + expect(failed.getAttribute('data-request-run-index')).toBe('0') + expect(failed.style.getPropertyValue('--request-boundary-offset')).toBe('0px') + expect(retry.getAttribute('data-request-run-index')).toBe('1') + expect(retry.style.getPropertyValue('--request-boundary-offset')).toBe('8px') + }) + it('renders responsive role icons with a custom tooltip', () => { const view = render() const toolTag = view.container.querySelector('[data-role-kind="tool"]') From ad6858b6d319caf8389e7c691f41c6ca9dbb963d Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 3 Aug 2026 15:02:05 +0800 Subject: [PATCH 33/52] fix(ui-trajectory): limit role tooltips to compact icons --- .../src/client/TrajectoryTable.tsx | 48 +++++++++---------- .../client/ui-trajectory/tests/table.spec.tsx | 9 ++-- 2 files changed, 30 insertions(+), 27 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 13dfee2606..7f314b6a40 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1961,36 +1961,36 @@ export function TrajectoryTable({ - - - - {KIND_LABEL[record.cell.kind]} - + + + {KIND_LABEL[record.cell.kind]} - + )} diff --git a/packages/client/ui-trajectory/tests/table.spec.tsx b/packages/client/ui-trajectory/tests/table.spec.tsx index aaea399a83..794fd8ca09 100644 --- a/packages/client/ui-trajectory/tests/table.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.spec.tsx @@ -263,19 +263,22 @@ describe('TrajectoryTable', () => { expect(retry.style.getPropertyValue('--request-boundary-offset')).toBe('8px') }) - it('renders responsive role icons with a custom tooltip', () => { + it('shows the custom role tooltip only from the responsive icon', () => { const view = render() const toolTag = view.container.querySelector('[data-role-kind="tool"]') + const toolIcon = toolTag?.querySelector('[data-role-icon="wrench"]') expect(toolTag).not.toBeNull() expect(toolTag?.getAttribute('title')).toBeNull() - expect(toolTag?.querySelector('[data-role-icon="wrench"]')).toBeTruthy() + expect(toolIcon).toBeTruthy() fireEvent.mouseEnter(toolTag as HTMLElement) + expect(screen.queryByRole('tooltip')).toBeNull() + fireEvent.mouseEnter(toolIcon as HTMLElement) const tooltip = screen.getByRole('tooltip') expect(tooltip.textContent).toBe('TOOL') expect(tooltip.getAttribute('data-side')).toBe('right') - fireEvent.mouseLeave(toolTag as HTMLElement) + fireEvent.mouseLeave(toolIcon as HTMLElement) expect(screen.queryByRole('tooltip')).toBeNull() }) From 59d923395a76db74846be329bbeb4b2bf1112db1 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Mon, 3 Aug 2026 00:14:35 -0700 Subject: [PATCH 34/52] fix(web): document remote welcome constraints --- apps/web/tests/scaffold.ts | 6 +++++- packages/client/connection/README.i18n.yaml | 4 ++-- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- packages/client/connection/src/loopback-hostname.ts | 9 +++++++++ .../ui-settings-general/src/client/welcome-store.ts | 4 +++- 6 files changed, 21 insertions(+), 6 deletions(-) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 62a1d31d0d..e1bb5b58c4 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -166,7 +166,11 @@ export interface LaunchOptions { } /** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */ welcomeNoticePending?: boolean - /** Browse through this trusted non-loopback hostname while the test server stays bound to loopback. */ + /** + * Browse through a trusted non-loopback hostname that the browser resolves + * to loopback (for example `*.localhost`). The test server stays bound to + * 127.0.0.1; a non-resolving authority fails before Host trust is exercised. + */ remoteAuthority?: string } diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 974e3014d6..e159696db3 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/connection/README.md -README.md: c8b7c4787cbcbf6a202fb944459a589fcadd7c8d -README.zh.md: 693420183ffa4fb20e1fecbff523a12261a45d45 +README.md: 522ae6a14a3b4b07e7f2917133d16a5e83433f69 +README.zh.md: 4eaed862df678997b328d7a4ddcab1f5254c7c60 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index c8b7c4787c..522ae6a14a 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. +Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The dedicated `./loopback-hostname` source subpath exposes the zero-dependency predicate shared by the `/api` Host fence and browser welcome-persistence selection; client bundlers inline this source entry, while plain Node cannot load it directly, so it must remain browser-safe and dependency-free. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. ## /api browser-trust fence diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 693420183f..4eaed862df 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。专用的 `./loopback-hostname` 源码子路径导出 `/api` Host fence 与浏览器欢迎页持久化选择共用的零依赖判定函数;客户端 bundler 会内联这一源码入口,而 plain Node 无法直接加载它,因此它必须保持浏览器安全且零依赖。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 ## /api 浏览器信任栅栏 diff --git a/packages/client/connection/src/loopback-hostname.ts b/packages/client/connection/src/loopback-hostname.ts index 8fd30445bd..5666f0714d 100644 --- a/packages/client/connection/src/loopback-hostname.ts +++ b/packages/client/connection/src/loopback-hostname.ts @@ -1,3 +1,12 @@ +/** + * Browser-safe, zero-dependency loopback classification shared by the `/api` + * Host fence and browser welcome-persistence selection. The dedicated + * `./loopback-hostname` source subpath is inlined into client bundles instead + * of loaded by plain Node, so this module must not add Node-only or runtime + * dependencies. + * @module @deepseek-ai/dsh-client-connection/loopback-hostname + */ + /** * Whether a normalized URL hostname names the local loopback authority. * @param hostname - WHATWG URL hostname (IPv6 literals retain brackets). diff --git a/packages/client/ui-settings-general/src/client/welcome-store.ts b/packages/client/ui-settings-general/src/client/welcome-store.ts index fdff28f052..c95c9e46d8 100644 --- a/packages/client/ui-settings-general/src/client/welcome-store.ts +++ b/packages/client/ui-settings-general/src/client/welcome-store.ts @@ -117,7 +117,9 @@ export class WelcomeNoticeStore { } /** - * Refresh only after the welcome step has begun reading durable state. + * Refresh only after welcome state has left idle. A memory-mode load retains + * acknowledgement so reconnect and settings-change refreshes do not reopen a + * process-local notice. * @param controller - welcome state owner whose current status decides whether to load. */ export function refreshWelcomeIfLoaded(controller: WelcomeNoticeStore): void { From 0e05bb81a31ddfed80da7fe52737aac598e4e446 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 3 Aug 2026 15:31:36 +0800 Subject: [PATCH 35/52] fix(ui-trajectory): offset recovered request boundaries --- .../src/client/TrajectoryTable.tsx | 15 ++++++------- .../client/ui-trajectory/tests/table.spec.tsx | 22 ++++++++++++++++--- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 7f314b6a40..89154626f0 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -459,17 +459,16 @@ function indexRequestNumbers( function indexRequestBoundaryRuns(records: readonly TableRecord[]): ReadonlyMap { const indexes = new Map() - let previous: TableRecord | undefined - let runIndex = 0 + let runLength = 0 for (const record of records) { - if (record.cell.requestOnly !== true) { - previous = record - runIndex = 0 + if (record.cell.requestOnly === true) { + indexes.set(record.cell.index, runLength++) continue } - runIndex = previous?.cell.requestOnly === true ? runIndex + 1 : 0 - indexes.set(record.cell.index, runIndex) - previous = record + if (runLength > 0 && record.groupStart && requestStep(record.group) !== undefined) { + indexes.set(record.cell.index, runLength) + } + runLength = 0 } return indexes } diff --git a/packages/client/ui-trajectory/tests/table.spec.tsx b/packages/client/ui-trajectory/tests/table.spec.tsx index 794fd8ca09..7ad4d79b6b 100644 --- a/packages/client/ui-trajectory/tests/table.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.spec.tsx @@ -225,7 +225,7 @@ describe('TrajectoryTable', () => { it('marks failed requests and lays coincident request markers left to right', () => { const turns: readonly TrajectoryTurnModel[] = [ { - turn: null, + turn: 1, groups: [{ title: 'Step 1', cells: [{ @@ -239,14 +239,27 @@ describe('TrajectoryTable', () => { }], }, { - turn: null, + turn: 2, groups: [{ - title: 'Step 2', + title: 'Step 1', cells: [{ index: 2, kind: 'message', text: '', requestOnly: true, + isError: true, + timeSeconds: 0.1, + }], + }], + }, + { + turn: 3, + groups: [{ + title: 'Step 1', + cells: [{ + index: 3, + kind: 'message', + text: 'Recovered response', timeSeconds: 0.1, }], }], @@ -256,11 +269,14 @@ describe('TrajectoryTable', () => { const failed = screen.getByRole('button', { name: 'Request #1' }) const retry = screen.getByRole('button', { name: 'Request #2' }) + const recovered = screen.getByRole('button', { name: 'Request #3' }) expect(failed.getAttribute('data-request-status')).toBe('error') expect(failed.getAttribute('data-request-run-index')).toBe('0') expect(failed.style.getPropertyValue('--request-boundary-offset')).toBe('0px') expect(retry.getAttribute('data-request-run-index')).toBe('1') expect(retry.style.getPropertyValue('--request-boundary-offset')).toBe('8px') + expect(recovered.getAttribute('data-request-run-index')).toBe('2') + expect(recovered.style.getPropertyValue('--request-boundary-offset')).toBe('16px') }) it('shows the custom role tooltip only from the responsive icon', () => { From c2b0cc7b51fded129b3d8033c561a629c6aaf83b Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 3 Aug 2026 16:19:41 +0800 Subject: [PATCH 36/52] fix(ui-trajectory): clarify collapsed thinking controls --- .../src/client/TrajectoryTable.module.css | 10 ++++++++++ .../ui-trajectory/src/client/TrajectoryTable.tsx | 3 ++- packages/client/ui-trajectory/tests/table.spec.tsx | 5 ++++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 40f4791d45..69d9d620e3 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -1248,9 +1248,19 @@ background: transparent; cursor: pointer; font: 600 12px/18px var(--dsw-font-family); + gap: 2px; user-select: none; } +.thinkingChevron { + flex: none; + transition: transform 120ms var(--ds-ease-in-out); +} + +.thinkingToggle[aria-expanded='true'] .thinkingChevron { + transform: rotate(90deg); +} + .thinkingToggle:hover { color: var(--dsw-alias-label-secondary); } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 89154626f0..491462d7b5 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1232,7 +1232,8 @@ function MarkdownRecordContent({ aria-expanded={thinkingExpanded} onClick={() => { onThinkingExpandedChange(!thinkingExpanded) }} > - {thinkingExpanded ? 'Thinking' : 'Thinking ...'} + {thinkingExpanded ? 'Hide thinking' : 'Show thinking'} + {thinkingExpanded && ( { render() fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ })) - const toggle = screen.getByRole('button', { name: 'Thinking ...' }) + const toggle = screen.getByRole('button', { name: 'Show thinking' }) + expect(toggle.getAttribute('aria-expanded')).toBe('false') expect(screen.queryByText(thinking)).toBeNull() fireEvent.click(toggle) + expect(screen.getByRole('button', { name: 'Hide thinking' })).toBe(toggle) + expect(toggle.getAttribute('aria-expanded')).toBe('true') expect(toggle.parentElement?.textContent?.length).toBeGreaterThan(thinking.length) }) From 9c261516ce3f8cddf8c4f66fdad8601c980f8cb8 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 3 Aug 2026 16:22:03 +0800 Subject: [PATCH 37/52] fix(ui-trajectory): preserve thinking label --- packages/client/ui-trajectory/src/client/TrajectoryTable.tsx | 2 +- packages/client/ui-trajectory/tests/table.spec.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 491462d7b5..1296ea24d3 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1232,7 +1232,7 @@ function MarkdownRecordContent({ aria-expanded={thinkingExpanded} onClick={() => { onThinkingExpandedChange(!thinkingExpanded) }} > - {thinkingExpanded ? 'Hide thinking' : 'Show thinking'} + Thinking {thinkingExpanded && ( diff --git a/packages/client/ui-trajectory/tests/table.spec.tsx b/packages/client/ui-trajectory/tests/table.spec.tsx index 1f1eb7d3ca..a80a256634 100644 --- a/packages/client/ui-trajectory/tests/table.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.spec.tsx @@ -101,12 +101,12 @@ describe('TrajectoryTable', () => { render() fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ })) - const toggle = screen.getByRole('button', { name: 'Show thinking' }) + const toggle = screen.getByRole('button', { name: 'Thinking' }) expect(toggle.getAttribute('aria-expanded')).toBe('false') expect(screen.queryByText(thinking)).toBeNull() fireEvent.click(toggle) - expect(screen.getByRole('button', { name: 'Hide thinking' })).toBe(toggle) + expect(screen.getByRole('button', { name: 'Thinking' })).toBe(toggle) expect(toggle.getAttribute('aria-expanded')).toBe('true') expect(toggle.parentElement?.textContent?.length).toBeGreaterThan(thinking.length) }) From b5f9fcdea45855a0ad9b481f7867c852374053ca Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 3 Aug 2026 16:24:26 +0800 Subject: [PATCH 38/52] fix(ui-trajectory): clarify assistant request timing --- packages/client/ui-trajectory/src/client/TrajectoryTable.tsx | 2 +- packages/client/ui-trajectory/tests/table.spec.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 1296ea24d3..6f37c852c5 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -2528,7 +2528,7 @@ export function TrajectoryTable({ )} {selectedAssistantRequestTarget !== undefined && ( { selectRequest(selectedAssistantRequestTarget, 'timing') }} diff --git a/packages/client/ui-trajectory/tests/table.spec.tsx b/packages/client/ui-trajectory/tests/table.spec.tsx index a80a256634..b9f5e5d7b7 100644 --- a/packages/client/ui-trajectory/tests/table.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.spec.tsx @@ -64,7 +64,7 @@ describe('TrajectoryTable', () => { it('shows assistant timing facts after keyboard selection', () => { render() fireEvent.keyDown(screen.getByRole('row', { name: /ASSISTANT/ }), { key: 'Enter' }) - fireEvent.click(screen.getByRole('button', { name: 'Timing' })) + fireEvent.click(screen.getByRole('button', { name: 'Request Timing' })) expect(screen.getByText('500 ms')).toBeTruthy() expect(screen.getByText('1.00 s')).toBeTruthy() From 7077d6befcf22b3888e65b355ad09f739312431d Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 3 Aug 2026 16:40:47 +0800 Subject: [PATCH 39/52] refactor(tui): resume rows fold titles only, timestamp from artifact mtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rows no longer read logs for anything but the batch title fold: the activity timestamp is a live session's last in-memory event time or the artifact mtime via the optional sessionPersistence.locate(), falling back to creation time; the last-turn, route, and goal columns are gone. Route availability moves to the Enter-time preflight, which already fully reads and replay-validates the one chosen log. The projectSessions public API this PR had added to session-query is reverted — the change is now confined to the TUI package. --- ...resume-selector-batch-projection.i18n.yaml | 4 +- ...-07-31-resume-selector-batch-projection.md | 24 ++- ...-31-resume-selector-batch-projection.zh.md | 22 ++- docs/cordis-catalog/services.md | 21 +-- .../session-query.i18n.yaml | 6 +- docs/core-data-structures/session-query.md | 19 -- docs/core-data-structures/session-query.zh.md | 19 -- .../cordis/tool-cordis/src/api-catalog.ts | 12 -- .../session-query/README.i18n.yaml | 4 +- .../session-query/session-query/README.md | 3 +- .../session-query/session-query/README.zh.md | 3 +- .../session-query/session-query/src/index.ts | 28 +-- .../session-query/tests/session-query.spec.ts | 27 --- packages/ui/tui/README.i18n.yaml | 4 +- packages/ui/tui/README.md | 4 +- packages/ui/tui/README.zh.md | 4 +- packages/ui/tui/src/chat/resume.ts | 137 +++++++++------ packages/ui/tui/src/components/dialogs.ts | 90 ++-------- packages/ui/tui/tests/harness.ts | 4 +- ...esume-sessions-all-workspaces.expected.txt | 33 ++-- .../snapshots/resume-sessions.expected.txt | 18 +- packages/ui/tui/tests/tui.snapshot.ts | 6 +- packages/ui/tui/tests/tui.spec.ts | 163 +++++++++++++----- scripts/gen-cordis-catalog.ts | 2 - scripts/type-equiv.manifest.json | 10 -- 25 files changed, 295 insertions(+), 372 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml index 9eb6325c04..7a51d1081c 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md -2026-07-31-resume-selector-batch-projection.md: e1777d67b6f1822fd11d2d38bc6fe45ed11b179f -2026-07-31-resume-selector-batch-projection.zh.md: 031293a76afbf88c8da16f61ddef65f891c05382 +2026-07-31-resume-selector-batch-projection.md: 8a0256da34b8d7de94d3f13b06fa41d591543fcf +2026-07-31-resume-selector-batch-projection.zh.md: a0357a06e95d4a7aad2a5646f9bf2b0946d5e167 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md index e1777d67b6..8a0256da34 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md @@ -1,4 +1,4 @@ -# Agent Note: Resume selector batch projection +# Agent Note: Resume selector folds titles only Status: implemented @@ -6,24 +6,30 @@ English | [中文](2026-07-31-resume-selector-batch-projection.zh.md) ## Problem -Opening the TUI `/resume` selector called `sessionQuery.readSession()` once per listed session under an unbounded `Promise.all`. Each call re-listed the whole persistence store inside `SessionCorpus.load()` (O(N²) listings), read and decompressed the complete log, replay-validated every event through the `Session` constructor, and deep-cloned the header and events up to three times — all to derive one selector row's title, last-activity time, last `turn/end` label, provider/model route, and goal phase. On a real store (185 sessions, 87 MB compressed, ~353k events) the selector took tens of seconds to open, and the cost grows with total log size rather than session count. +Opening the TUI `/resume` selector called `sessionQuery.readSession()` once per listed session under an unbounded `Promise.all`. Each call re-listed the whole persistence store inside `SessionCorpus.load()` (O(N²) listings), read and decompressed the complete log, replay-validated every event through the `Session` constructor, and deep-cloned the header and events up to three times — all to derive one selector row's title, last-activity time, last `turn/end` label, provider/model route, and goal phase. On a real store (185 sessions, 87 MB compressed, ~353k events) the selector took tens of seconds to open, and the cost grew with total log size rather than session count. ## Decision -`SessionQueryService` exposes the existing internal `SessionCorpus.projectMany` batch as public `projectSessions(sessionIds, project, signal?)`: one persistence listing, at most `persistedInspectConcurrency` concurrent persisted inspections, per-id failure isolation, and a synchronous projector over a borrowed `LogicalSessionSource` with no replay validation and no cloning. `readTitleSnapshots` now routes through it; `LogicalSessionSource` and `LogicalProjectionResult` are exported and documented in the session-query core-data-structures page. +Selector rows fold nothing but titles, and everything else a row shows comes from metadata: -The `/resume` selector builds all candidate rows from one `projectSessions` batch; a rejected projection degrades to that row's disabled "Unreadable session" fallback exactly as a failed `readSession` did. `summarizeResumeCandidate` takes the borrowed source and retains only the record and derived scalars. The pre-handoff preflight still reads the single chosen session through `readSession`, keeping full replay validation before the process re-execs; its redundant live-session shortcut was dropped because `readSession` is already live-preferred. +- Titles come from the existing public batch `readTitleSnapshots` — one persistence listing, at most `persistedInspectConcurrency` concurrent inspections, per-id failure isolation. This is the selector's only per-log read; a rejected title read degrades to that row's disabled "Unreadable session" fallback. +- The activity timestamp never reads a log: a live session uses its last in-memory event time; a persisted session stats the artifact named by the optional `sessionPersistence.locate()` (mtime), falling back to the header's creation time when the backend locates no per-session artifact (SQLite) or the stat fails. Any append moves the mtime, so a mere pickup boundary now floats a browsed session up — accepted as the price of a metadata-only timestamp. +- The last-turn label, provider/model route, and goal phase columns are gone from rows. Route availability is now enforced by the Enter-time preflight, which fully reads and replay-validates the one chosen log through `readSession` before handoff. -The selector overlay opens synchronously when `/resume` dispatches, before the scan settles: an `undefined` candidate set renders a "Loading sessions…" placeholder, the picker owns terminal input from its first frame (so keystrokes during a long scan reach the search field rather than the editor), Enter reports that sessions are still loading, and Escape cancels exactly as it does on the loaded list. Closing the overlay aborts the scan through the `AbortSignal` both service methods accept, so a dismissed picker does not keep decompressing a large store; a signal-ignoring backend's late settlement is dropped by a staleness check instead. The finished scan swaps rows in through `setCandidates` (which also clears a stale still-loading error) without replacing the overlay; a queued activation behind a closing predecessor receives an already-scanned set at construction; one catch spans listing and projection, so any scan failure closes the overlay and reports the existing failure notice rather than stranding the loading placeholder. +The selector overlay opens synchronously when `/resume` dispatches, before the scan settles: an `undefined` candidate set renders a "Loading sessions…" placeholder, the picker owns terminal input from its first frame, Enter reports that sessions are still loading, and Escape cancels. Closing the overlay aborts the scan through the `AbortSignal` the query methods accept; a signal-ignoring backend's late settlement is dropped by a staleness check. The finished scan swaps rows in through `setCandidates` (clearing a stale still-loading error) without replacing the overlay; a queued activation behind a closing predecessor receives an already-scanned set at construction; one catch spans listing, titles, and mtimes, so any scan failure closes the overlay and reports a notice rather than stranding the loading placeholder. + +The change is confined to the TUI package: no session-query or session-persistence surface changed. ## Alternatives considered -**Fix only the O(N²) listing inside `SessionCorpus.load()`.** Rejected as the primary fix: the per-candidate full decompress, replay validation, and triple clone dominate on large logs and remain O(total log bytes). The redundant pre-listing in `load()` is still a candidate cleanup, but it changes not-found/consistency error semantics and is not needed once the selector stops calling `readSession` per row. +**Keep per-row route/turn/goal columns via a generic batch projection (`projectSessions`).** Implemented first, then rejected: it still decompressed and parsed every log on every `/resume`, so browsing cost stayed O(total log bytes), and it grew the session-query public API for one consumer. The public seam was reverted; `readTitleSnapshots` keeps using the internal `projectMany` unchanged. -**A resume-specific summary method on `sessionQuery`.** Rejected: resume is a TUI concept, and the service seam should not import consumer vocabulary. The generic synchronous projection mirrors the seam `readTitleSnapshots` already used internally and lets the TUI own its fold. +**Fix only the O(N²) listing inside `SessionCorpus.load()`.** Rejected as the primary fix: the per-candidate full decompress, replay validation, and triple clone dominated on large logs. The redundant pre-listing in `load()` remains a candidate cleanup with error-semantics implications. -**A persisted summary index (e.g. in the SQLite query backend).** Rejected for now: one bounded pass over the store (~1–3 s on the measured machine) is acceptable selector latency, and an index adds an invalidation contract. Reintroduce if stores grow to where one bounded pass is still too slow. +**Surface a last-modified time through `listSnapshots`/`SessionRecord`.** Cleanest seam-wise, but touches the persistence contract, both backends, and the query record shape for what the TUI can already derive from `locate()` plus one stat. Reintroduce if a second consumer needs metadata activity times. + +**A persisted summary/title index.** Rejected for now: one bounded title pass is acceptable selector latency, and an index adds an invalidation contract. Reintroduce if title reads over large stores become the bottleneck. ## Consequences -Opening `/resume` performs one listing plus one bounded-concurrency pass instead of N listings and N validated full copies; memory stays bounded by the concurrency limit because each projected log is released before its worker dequeues another id. Selector rows are no longer replay-validated — a log that lists and parses but would fail replay shows as a normal row until preflight rejects it, which preflight always re-checks before handoff. Fake `sessionQuery` services in TUI tests must now provide `projectSessions` alongside `listSessions`/`readSession`. Because the picker takes focus immediately, starting a second scan requires dismissing the current overlay first — a second `/resume` typed during a scan lands in the search field, which is the intended input capture. +Opening `/resume` performs one listing, one stat per persisted row, and one bounded title pass instead of N listings and N validated full copies. Rows show title, timestamp, status, and id only; route problems surface as an Enter-time preflight error instead of a disabled row, and a session that fails replay is caught by preflight rather than the listing. Browsed-then-abandoned sessions float up on their pickup mtime. Fake `sessionQuery` services in TUI tests provide `readTitleSnapshots` alongside `listSessions`/`readSession`, and the test harness forwards an optional `locate`. Because the picker takes focus immediately, starting a second scan requires dismissing the current overlay first — a second `/resume` typed during a scan lands in the search field, which is the intended input capture. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md index 031293a76a..a0357a06e9 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 恢复选择器批量投影 +# Agent Note: 恢复选择器只折叠标题 Status: implemented @@ -10,20 +10,26 @@ Status: implemented ## Decision -`SessionQueryService` 将既有的内部 `SessionCorpus.projectMany` 批量能力公开为 `projectSessions(sessionIds, project, signal?)`:一次持久化列表查询、最多 `persistedInspectConcurrency` 个并发持久化检查、按 id 隔离失败,以及一个在借用的 `LogicalSessionSource` 上运行的同步投影函数——不做回放验证也不克隆。`readTitleSnapshots` 现在经由它实现;`LogicalSessionSource` 和 `LogicalProjectionResult` 被导出,并记录在 session-query 核心数据结构页面中。 +选择器行除标题外不折叠任何内容,行内其余信息全部来自元数据: -`/resume` 选择器通过一次 `projectSessions` 批量调用构建全部候选行;被拒绝的投影会退化为该行的禁用"Unreadable session"回退,与之前 `readSession` 失败时的行为完全一致。`summarizeResumeCandidate` 接受借用的来源,且只保留记录和推导出的标量。移交前的预检仍通过 `readSession` 读取用户选中的单个会话,在进程 re-exec 前保留完整回放验证;其中冗余的实时会话捷径被删除,因为 `readSession` 本身已是实时优先。 +- 标题来自既有的公开批量 `readTitleSnapshots`——一次持久化列表查询、最多 `persistedInspectConcurrency` 个并发检查、按 id 隔离失败。这是选择器唯一的按日志读取;标题读取被拒绝时退化为该行的禁用"Unreadable session"回退。 +- 活动时间戳从不读取日志:实时会话取内存中最后一个事件的时间;持久化会话对可选 `sessionPersistence.locate()` 命名的产物做 stat(mtime),当后端定位不到按会话的产物(SQLite)或 stat 失败时回退到 header 的创建时间。任何追加都会移动 mtime,因此仅仅一次 pickup 边界也会让浏览过的会话上浮——这是元数据时间戳的代价,予以接受。 +- 行内不再有最后轮次标签、提供方/模型路由和目标阶段列。路由可用性改由 Enter 时的预检强制:预检通过 `readSession` 完整读取并回放验证选中的那一份日志后才移交。 -选择器 overlay 在 `/resume` 分发时同步打开,早于扫描结算:`undefined` 候选集渲染"Loading sessions…"加载占位符,选择器从第一帧起就拥有终端输入(长扫描期间的按键会进入搜索字段而非编辑器),Enter 提示会话仍在加载,Escape 的取消方式与已加载列表完全相同。关闭 overlay 会通过两个服务方法都接受的 `AbortSignal` 中止扫描,因此被关闭的选择器不会继续解压大型存储;忽略信号的后端在中止后的迟到结算则由过期检查丢弃。扫描完成后通过 `setCandidates`(同时清除过期的仍在加载错误)换入行数据,不替换 overlay;排在正在关闭的前任之后的排队激活会在构造时直接收到已扫描的集合;列表查询与投影共用同一个 catch,因此任何扫描失败都会关闭 overlay 并报告既有的失败通知,而不会让加载占位符悬置。 +选择器 overlay 在 `/resume` 分发时同步打开,早于扫描结算:`undefined` 候选集渲染"Loading sessions…"加载占位符,选择器从第一帧起就拥有终端输入,Enter 提示会话仍在加载,Escape 取消。关闭 overlay 会通过查询方法接受的 `AbortSignal` 中止扫描;忽略信号的后端的迟到结算由过期检查丢弃。扫描完成后通过 `setCandidates`(同时清除过期的仍在加载错误)换入行数据,不替换 overlay;排在正在关闭的前任之后的排队激活会在构造时直接收到已扫描的集合;列表查询、标题与 mtime 共用同一个 catch,因此任何扫描失败都会关闭 overlay 并报告通知,而不会让加载占位符悬置。 + +改动局限于 TUI 包:session-query 与 session-persistence 的任何表面都未改变。 ## Alternatives considered -**只修复 `SessionCorpus.load()` 内部的 O(N²) 列表查询。** 作为主要修复被拒绝:在大日志上,按候选行执行的完整解压、回放验证和三重克隆才是主要开销,且仍是 O(日志总字节数)。`load()` 中的冗余预列表查询仍是一个候选清理项,但它会改变 not-found/一致性错误语义,而且一旦选择器不再按行调用 `readSession`,这项清理就不再必要。 +**通过通用批量投影(`projectSessions`)保留每行的路由/轮次/目标列。** 先实现后否决:它仍在每次 `/resume` 时解压并解析全部日志,浏览开销依旧是 O(日志总字节数),且为单一消费者扩大了 session-query 公开 API。该公开接缝已回退;`readTitleSnapshots` 继续使用内部 `projectMany`,保持不变。 -**在 `sessionQuery` 上添加恢复专用的摘要方法。** 被拒绝:恢复是 TUI 概念,服务接缝不应引入消费者词汇。通用同步投影复用了 `readTitleSnapshots` 已在内部使用的接缝,并让 TUI 拥有自己的 fold。 +**只修复 `SessionCorpus.load()` 内部的 O(N²) 列表查询。** 作为主要修复被否决:在大日志上,按候选行执行的完整解压、回放验证和三重克隆才是主要开销。`load()` 中的冗余预列表查询仍是一个候选清理项,但涉及错误语义。 -**持久化摘要索引(例如放在 SQLite 查询后端中)。** 暂时被拒绝:对存储做一次有界扫描(在测量机器上约 1–3 秒)是可接受的选择器延迟,而索引会引入失效契约。若存储增长到一次有界扫描仍然过慢时再重新引入。 +**通过 `listSnapshots`/`SessionRecord` 暴露最后修改时间。** 从接缝角度最干净,但要触碰持久化契约、两个后端和查询记录形状,而 TUI 已能用 `locate()` 加一次 stat 得到同样的信息。若出现第二个需要元数据活动时间的消费者再引入。 + +**持久化摘要/标题索引。** 暂时否决:一次有界标题扫描的选择器延迟可接受,而索引会引入失效契约。若大型存储上的标题读取成为瓶颈再引入。 ## Consequences -打开 `/resume` 只执行一次列表查询加一次有界并发扫描,而不是 N 次列表查询和 N 份经验证的完整副本;内存受并发上限约束,因为每个投影完的日志会在其 worker 出队下一个 id 前被释放。选择器行不再经过回放验证——一份可列出、可解析但回放会失败的日志会显示为普通行,直到预检拒绝它,而预检在移交前总会重新检查。TUI 测试中的伪造 `sessionQuery` 服务现在必须在 `listSessions`/`readSession` 之外提供 `projectSessions`。由于选择器立即接管焦点,启动第二次扫描需要先关闭当前 overlay——扫描期间输入的第二个 `/resume` 会落入搜索字段,这正是预期的输入捕获行为。 +打开 `/resume` 只执行一次列表查询、每个持久化行一次 stat、一次有界标题扫描,而不是 N 次列表查询和 N 份经验证的完整副本。行内只显示标题、时间戳、状态和 id;路由问题以 Enter 时预检错误的形式出现,而不再是禁用行;回放会失败的会话由预检而非列表阶段拦截。浏览后放弃的会话会因 pickup 的 mtime 上浮。TUI 测试中的伪造 `sessionQuery` 服务在 `listSessions`/`readSession` 之外提供 `readTitleSnapshots`,测试 harness 会转发可选的 `locate`。由于选择器立即接管焦点,启动第二次扫描需要先关闭当前 overlay——扫描期间输入的第二个 `/resume` 会落入搜索字段,这正是预期的输入捕获行为。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 556fc6955f..2f67906078 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1462,23 +1462,6 @@ async readTitleSnapshot( sessionId: SessionId, signal?: AbortSignal, ): Promise< */ async readTitleSnapshots( sessionIds: readonly SessionId[], signal?: AbortSignal, ): Promise -/** - * Project unique logical sessions synchronously from one cancellable corpus - * observation. - * - * Each source is a borrowed raw log without replay validation or cloning, so - * a batch summary costs one bounded read per persisted session instead of a - * full validated copy; the projector must clone anything it retains beyond - * its own call. Results preserve first-occurrence input order. Operational - * failures stay isolated per session, while cancellation rejects the - * complete operation. - * @param sessionIds - live or persisted session ids to observe. - * @param project - synchronous fold that owns/clones every retained value. - * @param signal - optional cancellation shared by all source reads. - * @returns one fulfilled or rejected result per unique requested id. - */ -async projectSessions( sessionIds: readonly SessionId[], project: (source: LogicalSessionSource) => Value, signal?: AbortSignal, ): Promise[]> - /** * List lightweight raw-log event records for one logical session. * @param sessionId - live-preferred session id to read. @@ -1529,9 +1512,9 @@ async traceEvent(request: SessionEventTraceRequest, signal?: AbortSignal): Promi async readEvent(request: SessionEventReadRequest, signal?: AbortSignal): Promise ``` -Types: [LogicalProjectionResult](../core-data-structures/session-query.md) · [LogicalSessionSource](../core-data-structures/session-query.md) · [SessionEventReadRequest](../core-data-structures/session-query.md) · [SessionEventRecord](../core-data-structures/session-query.md) · [SessionEventResultFilter](../core-data-structures/session-query.md) · [SessionEventSearchDocument](../core-data-structures/session-query.md) · [SessionEventSearchPage](../core-data-structures/session-query.md) · [SessionEventSearchRequest](../core-data-structures/session-query.md) · [SessionEventTraceObservation](../core-data-structures/session-query.md) · [SessionEventTraceRequest](../core-data-structures/session-query.md) · [SessionEventWindow](../core-data-structures/session-query.md) · [SessionId](../core-data-structures/core.md) · [SessionLineageTrace](../core-data-structures/session-query.md) · [SessionLogSnapshot](../core-data-structures/session-query.md) · [SessionRecord](../core-data-structures/session-query.md) · [SessionResultFilter](../core-data-structures/session-query.md) · [SessionSearchExecContext](../core-data-structures/session-query.md) · [SessionSearchHit](../core-data-structures/session-query.md) · [SessionSearchPage](../core-data-structures/session-query.md) · [SessionSearchRequest](../core-data-structures/session-query.md) · [SessionSurfaceSnapshot](../core-data-structures/session-query.md) · [SessionTitleObservation](../core-data-structures/session-query.md) · [SessionTitleObservationResult](../core-data-structures/session-query.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) +Types: [SessionEventReadRequest](../core-data-structures/session-query.md) · [SessionEventRecord](../core-data-structures/session-query.md) · [SessionEventResultFilter](../core-data-structures/session-query.md) · [SessionEventSearchDocument](../core-data-structures/session-query.md) · [SessionEventSearchPage](../core-data-structures/session-query.md) · [SessionEventSearchRequest](../core-data-structures/session-query.md) · [SessionEventTraceObservation](../core-data-structures/session-query.md) · [SessionEventTraceRequest](../core-data-structures/session-query.md) · [SessionEventWindow](../core-data-structures/session-query.md) · [SessionId](../core-data-structures/core.md) · [SessionLineageTrace](../core-data-structures/session-query.md) · [SessionLogSnapshot](../core-data-structures/session-query.md) · [SessionRecord](../core-data-structures/session-query.md) · [SessionResultFilter](../core-data-structures/session-query.md) · [SessionSearchExecContext](../core-data-structures/session-query.md) · [SessionSearchHit](../core-data-structures/session-query.md) · [SessionSearchPage](../core-data-structures/session-query.md) · [SessionSearchRequest](../core-data-structures/session-query.md) · [SessionSurfaceSnapshot](../core-data-structures/session-query.md) · [SessionTitleObservation](../core-data-structures/session-query.md) · [SessionTitleObservationResult](../core-data-structures/session-query.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) -Source: [`packages/session-query/session-query/src/index.ts:82`](../../packages/session-query/session-query/src/index.ts) +Source: [`packages/session-query/session-query/src/index.ts:81`](../../packages/session-query/session-query/src/index.ts) ## `ctx.sessionReferences` — `SessionReferenceService` diff --git a/docs/core-data-structures/session-query.i18n.yaml b/docs/core-data-structures/session-query.i18n.yaml index fc00c40e0a..f9c7355148 100644 --- a/docs/core-data-structures/session-query.i18n.yaml +++ b/docs/core-data-structures/session-query.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write docs/core-data-structures/session-query.md -session-query.md: cffbd792e8cab6e79365ceb4e0b1d996e7e93cf5 -session-query.zh.md: 2b67761ec3bcd96ee9de15511ec44516f1fe525d +# pnpm run verify-translation-pairing --write +session-query.md: d92af4bac34f7d41457e9e193111c3a53fe8022e +session-query.zh.md: ecf330b0a361ffae352a91c0d35524444936606d diff --git a/docs/core-data-structures/session-query.md b/docs/core-data-structures/session-query.md index cffbd792e8..d92af4bac3 100644 --- a/docs/core-data-structures/session-query.md +++ b/docs/core-data-structures/session-query.md @@ -84,25 +84,6 @@ type SessionTitleObservationResult = } ``` -`projectSessions` batches arbitrary synchronous folds over the same live-preferred corpus: each `LogicalSessionSource` is a borrowed raw log — never replay-validated or cloned — that is valid only for the projector call, so a batch summary costs one bounded read per persisted session. Each `LogicalProjectionResult` settles per unique requested id under the same isolation and cancellation rules as batch title reads. - -```ts type-equiv -/** Borrowed source visible only during one synchronous batch projection. */ -interface LogicalSessionSource { - /** Header selected with `events`; callers must clone retained output. */ - readonly header: SessionHeader - /** Raw events selected with `header`; valid only for the projection call. */ - readonly events: readonly SessionEvent[] -} -``` - -```ts type-equiv -/** One source-projection result in a batch logical-corpus observation. */ -type LogicalProjectionResult = - | { sessionId: SessionId; status: 'fulfilled'; value: Value } - | { sessionId: SessionId; status: 'rejected'; reason: unknown } -``` - ```ts type-equiv /** Lightweight metadata for one event within a logical session. */ interface SessionEventRecord { diff --git a/docs/core-data-structures/session-query.zh.md b/docs/core-data-structures/session-query.zh.md index 2b67761ec3..ecf330b0a3 100644 --- a/docs/core-data-structures/session-query.zh.md +++ b/docs/core-data-structures/session-query.zh.md @@ -84,25 +84,6 @@ type SessionTitleObservationResult = } ``` -`projectSessions` 在同一实时优先语料库上批量执行任意同步折叠:每个 `LogicalSessionSource` 都是借用的原始日志——从不做回放验证,也从不克隆——仅在投影函数调用期间有效,因此一次批量摘要对每个持久化会话只需一次有界读取。每个 `LogicalProjectionResult` 按唯一请求 id 结算,其失败隔离与取消规则与批量标题读取一致。 - -```ts type-equiv -/** Borrowed source visible only during one synchronous batch projection. */ -interface LogicalSessionSource { - /** Header selected with `events`; callers must clone retained output. */ - readonly header: SessionHeader - /** Raw events selected with `header`; valid only for the projection call. */ - readonly events: readonly SessionEvent[] -} -``` - -```ts type-equiv -/** One source-projection result in a batch logical-corpus observation. */ -type LogicalProjectionResult = - | { sessionId: SessionId; status: 'fulfilled'; value: Value } - | { sessionId: SessionId; status: 'rejected'; reason: unknown } -``` - ```ts type-equiv /** Lightweight metadata for one event within a logical session. */ interface SessionEventRecord { diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index bb2f5f8102..7091f95447 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -682,10 +682,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async readTitleSnapshots( sessionIds: readonly SessionId[], signal?: AbortSignal, ): Promise', jsDoc: '/**\n * Fold titles for unique sessions from one cancellable corpus observation.\n *\n * Results preserve first-occurrence input order. Operational failures stay\n * isolated per session, while cancellation rejects the complete operation.\n * @param sessionIds - live or persisted session ids to observe.\n * @param signal - optional cancellation shared by all source reads.\n * @returns one fulfilled or rejected result per unique requested id.\n */', }, - { - signature: 'async projectSessions( sessionIds: readonly SessionId[], project: (source: LogicalSessionSource) => Value, signal?: AbortSignal, ): Promise[]>', - jsDoc: '/**\n * Project unique logical sessions synchronously from one cancellable corpus\n * observation.\n *\n * Each source is a borrowed raw log without replay validation or cloning, so\n * a batch summary costs one bounded read per persisted session instead of a\n * full validated copy; the projector must clone anything it retains beyond\n * its own call. Results preserve first-occurrence input order. Operational\n * failures stay isolated per session, while cancellation rejects the\n * complete operation.\n * @param sessionIds - live or persisted session ids to observe.\n * @param project - synchronous fold that owns/clones every retained value.\n * @param signal - optional cancellation shared by all source reads.\n * @returns one fulfilled or rejected result per unique requested id.\n */', - }, { signature: 'async listEvents(sessionId: SessionId): Promise', jsDoc: '/**\n * List lightweight raw-log event records for one logical session.\n * @param sessionId - live-preferred session id to read.\n * @returns event records in ascending seq order.\n */', @@ -2127,14 +2123,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'LlmResolvedModelInfo', declaration: 'export interface LlmResolvedModelInfo extends LlmModelInfo {\n context?: LlmModelContext;\n defaultMaxTokens?: number;\n reasoning?: LlmModelReasoningInfo;\n}', }, - { - name: 'LogicalProjectionResult', - declaration: 'export type LogicalProjectionResult = {\n sessionId: SessionId;\n status: \'fulfilled\';\n value: Value;\n} | {\n sessionId: SessionId;\n status: \'rejected\';\n reason: unknown;\n};', - }, - { - name: 'LogicalSessionSource', - declaration: 'export interface LogicalSessionSource {\n readonly header: SessionHeader;\n readonly events: readonly SessionEvent[];\n}', - }, { name: 'ManualCompactAgentContext', declaration: 'export interface ManualCompactAgentContext extends CompactAgentContext {\n reserveTurnAdmission(): (() => void) | undefined;\n}', diff --git a/packages/session-query/session-query/README.i18n.yaml b/packages/session-query/session-query/README.i18n.yaml index 0ac53ddb14..fb266a82de 100644 --- a/packages/session-query/session-query/README.i18n.yaml +++ b/packages/session-query/session-query/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-query/session-query/README.md -README.md: 15ab403100b45e35808e95f84dcd8ab521854c66 -README.zh.md: 5e3cbfa0d13ba4884d0eb2cc1b4506fbfdef2446 +README.md: df97333be3b2c2cf71dd8c9287959bcbd83a5063 +README.zh.md: 1a3df1ce38360975d88a9f578b071b29cefbba0f diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index 15ab403100..df97333be3 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -11,14 +11,13 @@ English | [中文](README.zh.md) - `filterSessions(filters, signal?)` applies provider-independent session metadata and availability predicates to that same cloned logical corpus. - `filterEvents(sessionId, filters)` extracts first-party semantic documents and applies provider-independent metadata and literal-text predicates in ascending seq order. - `readTitleSnapshots(sessionIds, signal?)` resolves unique ids from one live-preferred corpus observation, passes cancellation through persisted listing and inspection, and returns ordered per-session settlements so one missing or malformed title source does not discard its peers. Each live source is folded directly, and each persisted worker folds to a detached header/title result and releases the full log before dequeuing another id. Cancellation rejects the whole batch. `readTitleSnapshot(sessionId, signal?)` is the one-observation view; `readTitle(sessionId, signal?)` returns only its optional folded `session/title`. -- `projectSessions(sessionIds, project, signal?)` runs one synchronous caller fold per unique id under the same batched corpus observation, isolation, and cancellation rules as `readTitleSnapshots`. Each source is a borrowed raw log — never replay-validated or cloned — valid only for the projector call, so a batch summary (for example the resume selector) scales with what the projector retains instead of total log size; the projector must clone anything it keeps. - `listEvents(sessionId)` loads the live-preferred raw log and classifies each event as `current`, `shadowed`, or `log-only` with the shared `dsh-session` surface fold. - `readSurface(sessionId)` returns one cloned header, raw-log capture boundary, and the complete folded current surface in model-history order. A live session wins over persistence; compaction is observed before or after its replacement append, never as a synthetic mixture. - `readEvent(request, signal?)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`. - `traceSession(sessionId, signal?)` reads the corpus once and returns immediate-to-outward ancestors plus deterministic recursive descendant trees. `complete: false` identifies the first missing parent; a target-connected cycle fails with `SESSION_QUERY_INVALID_LINEAGE`. - `traceEvent(request, signal?)` loads the logical log once and returns its cloned source header with direct positional replacements and direct logged provenance. `replacementChain` follows positional replacers to the final replacement; provenance links remain non-transitive. -Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A title read, event trace, or event read targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. Lineage-trace cancellation is passed to persisted listing; event-trace and event-read cancellation is passed to persisted listing and inspection. Each waits for the started backend call to settle, then rejects with the signal's exact reason even when the backend ignored that signal. A pre-aborted known-live title read, event trace, or event read rejects before folding or snapshotting without consulting persistence. A batch observation — titles or caller projections — performs one metadata listing, inspects its unique persisted ids with at most `persistedInspectConcurrency` workers, and preserves each result's own observed header for downstream authorization. Cancellation starts no queued inspections and rejects only after already-started workers settle. `listSessions()` remains lightweight and does not load logs or index titles. +Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A title read, event trace, or event read targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. Lineage-trace cancellation is passed to persisted listing; event-trace and event-read cancellation is passed to persisted listing and inspection. Each waits for the started backend call to settle, then rejects with the signal's exact reason even when the backend ignored that signal. A pre-aborted known-live title read, event trace, or event read rejects before folding or snapshotting without consulting persistence. A batch title observation performs one metadata listing, inspects its unique persisted ids with at most `persistedInspectConcurrency` workers, and preserves each title's own observed header for downstream authorization. Cancellation starts no queued inspections and rejects only after already-started workers settle. `listSessions()` remains lightweight and does not load logs or index titles. ## Filtering and extraction diff --git a/packages/session-query/session-query/README.zh.md b/packages/session-query/session-query/README.zh.md index 5e3cbfa0d1..1a3df1ce38 100644 --- a/packages/session-query/session-query/README.zh.md +++ b/packages/session-query/session-query/README.zh.md @@ -11,14 +11,13 @@ - `filterSessions(filters, signal?)` 对同一份克隆逻辑语料库应用与提供方无关的会话元数据和可用性谓词。 - `filterEvents(sessionId, filters)` 提取第一方语义文档,并按 seq 升序应用与提供方无关的元数据和字面文本谓词。 - `readTitleSnapshots(sessionIds, signal?)` 从一次实时优先的语料库观察中解析唯一 id,将取消信号传递给持久化列表查询和检查,并按顺序返回每个会话的结算结果,使某个缺失或格式错误的标题来源不会丢弃其他来源。每个实时来源直接 fold,每个持久化 worker fold 为脱离存储的 header/标题结果,并在出队下一个 id 前释放完整日志。取消会拒绝整个批次。`readTitleSnapshot(sessionId, signal?)` 是单次观察视图;`readTitle(sessionId, signal?)` 只返回其可选的 folded `session/title`。 -- `projectSessions(sessionIds, project, signal?)` 按唯一 id 各执行一次调用方的同步 fold,其批量语料库观察、失败隔离和取消规则与 `readTitleSnapshots` 相同。每个来源都是借用的原始日志——从不做回放验证,也从不克隆——仅在投影函数调用期间有效,因此一次批量摘要(例如恢复选择器)的开销取决于投影函数保留的内容,而不是日志总大小;投影函数必须克隆它要保留的任何值。 - `listEvents(sessionId)` 加载实时优先的原始日志,将每个事件分类为 `current`、`shadowed` 或 `log-only`;该分类使用共享 `dsh-session` 表层 fold。 - `readSurface(sessionId)` 返回一个克隆 header、原始日志捕获边界,以及按模型历史顺序排列的完整折叠后当前表层。实时会话优先于持久化;压缩(compaction)只会在其替换追加之前或之后被观察,绝不会出现合成混合。 - `readEvent(request, signal?)` 返回一个克隆 header、完整目标事件和有界的原始 seq 窗口。`before` 和 `after` 默认为 0,且不得超过 `readWindowMax`。 - `traceSession(sessionId, signal?)` 只读取一次语料库,返回从直接父级向外的祖先,以及确定性的递归后代树。`complete: false` 标识第一个缺失父级;与目标相连的循环会以 `SESSION_QUERY_INVALID_LINEAGE` 失败。 - `traceEvent(request, signal?)` 只加载一次逻辑日志,返回其克隆源 header、直接位置替换和直接已记录来源信息。`replacementChain` 沿位置替换者跟踪到最终替换;来源链接仍不传递。 -持久化是可选的,可动态挂载或卸载。已挂载持久化无法读取时,跨语料库列表和血缘跟踪以 `SESSION_QUERY_PERSISTENCE_FAILED` 失败。针对已知实时会话的标题读取、事件跟踪或事件读取不会查询持久化,因此持久化后端的健康状态无法使当前内存状态变得不可读。持久化标题和事件操作在加载前先执行列表查询,并在元数据不匹配时拒绝,而不会组合不一致的观察。血缘跟踪的取消信号会传递给持久化列表查询;事件跟踪和事件读取的取消信号会传递给持久化列表查询和检查。每项操作都会等待已启动的后端调用结算,然后使用信号的精确原因拒绝,即使后端忽略了该信号。针对已知实时会话且预先中止的标题读取、事件跟踪或事件读取会在 fold 或快照之前拒绝,且不查询持久化。批量观察——标题或调用方投影——执行一次元数据列表查询,使用最多 `persistedInspectConcurrency` 个 worker 检查唯一持久化 id,并保留每个结果自己观察到的 header,供下游授权使用。取消不会启动已排队检查,且只在已启动 worker 结算后拒绝。`listSessions()` 仍保持轻量,不加载日志或索引标题。 +持久化是可选的,可动态挂载或卸载。已挂载持久化无法读取时,跨语料库列表和血缘跟踪以 `SESSION_QUERY_PERSISTENCE_FAILED` 失败。针对已知实时会话的标题读取、事件跟踪或事件读取不会查询持久化,因此持久化后端的健康状态无法使当前内存状态变得不可读。持久化标题和事件操作在加载前先执行列表查询,并在元数据不匹配时拒绝,而不会组合不一致的观察。血缘跟踪的取消信号会传递给持久化列表查询;事件跟踪和事件读取的取消信号会传递给持久化列表查询和检查。每项操作都会等待已启动的后端调用结算,然后使用信号的精确原因拒绝,即使后端忽略了该信号。针对已知实时会话且预先中止的标题读取、事件跟踪或事件读取会在 fold 或快照之前拒绝,且不查询持久化。批量标题观察执行一次元数据列表查询,使用最多 `persistedInspectConcurrency` 个 worker 检查唯一持久化 id,并保留每个标题自己观察到的 header,供下游授权使用。取消不会启动已排队检查,且只在已启动 worker 结算后拒绝。`listSessions()` 仍保持轻量,不加载日志或索引标题。 ## 过滤与提取 diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index cf3dd95e39..809971b798 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -36,7 +36,7 @@ import { SessionQueryError, type Config, } from './config.ts' -import { SessionCorpus, type LogicalProjectionResult, type LogicalSessionSource } from './corpus.ts' +import { SessionCorpus } from './corpus.ts' import { buildSessionEventSearchDocuments } from './documents.ts' import { filterSessionEventDocuments, @@ -64,7 +64,6 @@ export { materializeSessionResultFilters, } from './filters.ts' export { assertSessionHeadersCompatible } from './sources.ts' -export type { LogicalProjectionResult, LogicalSessionSource } from './corpus.ts' declare module 'cordis' { interface Context { @@ -206,7 +205,7 @@ export abstract class SessionQueryService extends Service { sessionIds: readonly SessionId[], signal?: AbortSignal, ): Promise { - return this.projectSessions(sessionIds, (source): SessionTitleObservation => { + return this._corpus.projectMany(sessionIds, (source): SessionTitleObservation => { const title = foldSessionTitle(source.events) return { session: structuredClone(source.header), @@ -215,29 +214,6 @@ export abstract class SessionQueryService extends Service { }, signal) } - /** - * Project unique logical sessions synchronously from one cancellable corpus - * observation. - * - * Each source is a borrowed raw log without replay validation or cloning, so - * a batch summary costs one bounded read per persisted session instead of a - * full validated copy; the projector must clone anything it retains beyond - * its own call. Results preserve first-occurrence input order. Operational - * failures stay isolated per session, while cancellation rejects the - * complete operation. - * @param sessionIds - live or persisted session ids to observe. - * @param project - synchronous fold that owns/clones every retained value. - * @param signal - optional cancellation shared by all source reads. - * @returns one fulfilled or rejected result per unique requested id. - */ - async projectSessions( - sessionIds: readonly SessionId[], - project: (source: LogicalSessionSource) => Value, - signal?: AbortSignal, - ): Promise[]> { - return this._corpus.projectMany(sessionIds, project, signal) - } - /** * List lightweight raw-log event records for one logical session. * @param sessionId - live-preferred session id to read. diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 0613673f88..2713de9a72 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -548,33 +548,6 @@ describe('session-query exact reads', () => { expect(TestPersistence.inspectSignals).toEqual([signal, signal]) }) - it('projects borrowed raw logs in one corpus scan with per-session failure isolation', async () => { - const persisted = header('project-persisted', 1) - TestPersistence.reset([{ meta: persisted, events: eventLog('persisted-projection') }]) - const ctx = await liveContext() - const live = ctx.sessions.create(SessionId('project-live'), { meta: { createdAt: 2 } }) - live.append('session/title', { - title: 'Live projection', - messageSeqs: [], - source: { kind: 'fallback' }, - }) - await ctx.plugin(TestPersistence) - const missing = SessionId('project-missing') - - const results = await ctx.sessionQuery.projectSessions( - [live.id, persisted.id, missing], - source => ({ id: source.header.id, eventCount: source.events.length }), - ) - - expect(results).toMatchObject([ - { sessionId: live.id, status: 'fulfilled', value: { id: live.id, eventCount: 1 } }, - { sessionId: persisted.id, status: 'fulfilled', value: { id: persisted.id, eventCount: 1 } }, - { sessionId: missing, status: 'rejected' }, - ]) - expect(TestPersistence.listCalls).toBe(1) - expect(TestPersistence.inspectCalls).toEqual([persisted.id]) - }) - it('bounds persisted title inspection concurrency while preserving ordered results', async () => { const entries = Array.from({ length: 12 }, (_, index) => { const meta = header(`bounded-title-${index}`, index) diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index 452ce7a5e7..1715dfa776 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/tui/README.md -README.md: 387c7df29f493650ac74649ab60916436f1a9b12 -README.zh.md: be3c757d343ac62703fcdec4912c481077e397e3 +README.md: 7e32af76b3cc7a3d5e99b4a587e66acd31d02496 +README.zh.md: be1cdcc2775536cf442d3e7cccaf0e93eea65ab9 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 387c7df29f..7e32af76b3 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -34,9 +34,9 @@ The footer sums the session's reported usage as `↑ `/resume` opens a full-viewport keyboard selector instead of a centered dialog. The selector opens as soon as the command runs and takes input focus while the session scan is still pending, showing a loading placeholder until the rows arrive; Escape cancels an in-flight scan the same way it cancels the loaded list. Two scopes cover the same candidate set: the current workspace, which it opens on, and all workspaces, which Tab toggles to. The scope line under the search field names the active scope and the count the other holds, and each row in the all-workspaces scope also reports its own workspace. Toggling clears the search and selection so the highlighted row always belongs to the visible list. -Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Candidates are sorted by last logged activity and searchable by log-backed title or session id, and by workspace label in the all-workspaces scope; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, a session with no recorded workspace to run in, or a session whose logged provider has no current adapter remains visible but disabled; a workspace other than the current one is a scope rather than a disabled reason, because resume enters that directory. +Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Rows fold nothing but each log's title (one bounded batch read): candidates are sorted by metadata activity — a live session's last in-memory event time, otherwise the persisted artifact's mtime, falling back to creation time — and searchable by title or session id, and by workspace label in the all-workspaces scope; each row reports that timestamp plus current/live/persisted state and the id. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, or a session with no recorded workspace to run in remains visible but disabled; a workspace other than the current one is a scope rather than a disabled reason, because resume enters that directory. -Selection repeats those checks and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume` with the selected id and the workspace re-read at preflight: process cwd, not the restored session header, is what filesystem and shell tools resolve against, so the host must enter that directory. Where `process.execve` is available, the shipped `dsh` host chdirs into it before disposing the app and replacing its process, and rejects an unreachable directory while the terminal can still be restored. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`. +Selection repeats those checks, fully reads and replay-validates the one chosen log, rejects it when its logged provider has no current adapter, and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume` with the selected id and the workspace re-read at preflight: process cwd, not the restored session header, is what filesystem and shell tools resolve against, so the host must enter that directory. Where `process.execve` is available, the shipped `dsh` host chdirs into it before disposing the app and replacing its process, and rejects an unreachable directory while the terminal can still be restored. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`. The exit line is launcher-owned, not configurable. A launcher provides `TUI_GOODBYE_MESSAGE_KEY` on the boot context — for the shipped `dsh`, the command that resumes this session — and exiting prints it verbatim after the terminal is released; absent, exiting prints nothing. Only the launcher knows how it was invoked, so only it can name a command that works. The TUI escapes terminal controls before rendering and never executes the text. A launcher that also supplies `MAIN_SESSION_ID_KEY` fixes which session the mounted app binds to, so resume survives any config-level patch. diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index be3c757d34..be1cdcc277 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -34,9 +34,9 @@ Footer 将会话报告的用量汇总为 `↑`;任 `/resume` 会打开全 viewport 键盘选择器,而非居中对话框。选择器在命令执行时立即打开并接管输入焦点,会话扫描仍在进行时显示加载占位符,直到行数据就绪;Escape 取消进行中的扫描,方式与取消已加载列表相同。两个作用域覆盖同一候选项集合:打开时所处的当前工作区,以及按 Tab 切换到的所有工作区。搜索字段下方的作用域行会给出当前作用域的名称以及另一个作用域包含的数量,且在所有工作区作用域中每行还会报告自身所属的工作区。切换会清除搜索与选择,使高亮行始终属于可见列表。 -获得焦点的搜索字段紧跟搜索 glyph 开始,并发出 pi-tui 的 cursor marker,使终端 IME 组合保持锚定在字段内。候选项按最近记录的活动排序,可按日志支持的标题或会话 id 搜索,在所有工作区作用域中还可按工作区标签搜索;每行报告 current/live/persisted 状态、上一轮次结果、近期提供方/模型,以及存在时的持久目标阶段。Up/Down 与 Page Up/Page Down 导航,Enter 恢复,Escape 会先清除非空搜索,再次按下才取消,Ctrl+C 则直接取消。当前会话、已在本运行时中活跃的会话、不可读日志、没有可运行的已记录工作区的会话,或日志所记提供方没有当前适配器的会话仍会显示,但不可选择;不同于当前工作区的工作区属于作用域而非禁用原因,因为恢复会进入该目录。 +获得焦点的搜索字段紧跟搜索 glyph 开始,并发出 pi-tui 的 cursor marker,使终端 IME 组合保持锚定在字段内。行数据除每份日志的标题(一次有界批量读取)外不折叠任何内容:候选项按元数据活动时间排序——实时会话取内存中最后一个事件的时间,否则取持久化产物的 mtime,再回退到创建时间——可按标题或会话 id 搜索,在所有工作区作用域中还可按工作区标签搜索;每行报告该时间戳、current/live/persisted 状态和 id。Up/Down 与 Page Up/Page Down 导航,Enter 恢复,Escape 会先清除非空搜索,再次按下才取消,Ctrl+C 则直接取消。当前会话、已在本运行时中活跃的会话、不可读日志,或没有可运行的已记录工作区的会话仍会显示,但不可选择;不同于当前工作区的工作区属于作用域而非禁用原因,因为恢复会进入该目录。 -选择时会重复这些检查,并要求当前 agent 空闲,随后 flush 当前会话。TUI 接着停止终端 UI,并以所选 id 和在预检时重新读取的工作区调用由宿主持有的可选 `TuiRuntime.handoffResume`:文件系统与 shell 工具解析所依据的是进程 cwd,而非恢复出的会话头部,因此宿主必须进入该目录。存在 `process.execve` 时,发布的 `dsh` 宿主会先 chdir 进入该目录,再对 app 执行 dispose 并替换自身进程,并在终端仍可恢复时拒绝不可达的目录。恢复操作保留相同的 `SessionId`、transcript、标题、todo 和持久目标;目标激活仍保持解除,TUI 会要求用户确认或执行 `/goal resume`。 +选择时会重复这些检查,完整读取并回放验证所选中的那一份日志,在其日志所记提供方没有当前适配器时拒绝,并要求当前 agent 空闲,随后 flush 当前会话。TUI 接着停止终端 UI,并以所选 id 和在预检时重新读取的工作区调用由宿主持有的可选 `TuiRuntime.handoffResume`:文件系统与 shell 工具解析所依据的是进程 cwd,而非恢复出的会话头部,因此宿主必须进入该目录。存在 `process.execve` 时,发布的 `dsh` 宿主会先 chdir 进入该目录,再对 app 执行 dispose 并替换自身进程,并在终端仍可恢复时拒绝不可达的目录。恢复操作保留相同的 `SessionId`、transcript、标题、todo 和持久目标;目标激活仍保持解除,TUI 会要求用户确认或执行 `/goal resume`。 退出时打印的行由启动器拥有,不可通过配置指定。启动器在启动上下文上提供 `TUI_GOODBYE_MESSAGE_KEY`(对于随附的 `dsh`,即恢复本会话的命令),释放终端后退出会原样打印它;未提供时退出不打印任何内容。只有启动器知道自己是如何被调用的,因此只有它能给出可用的命令。TUI 在渲染前会转义终端控制字符,且绝不执行该文本。若启动器同时提供 `MAIN_SESSION_ID_KEY`,则会固定已挂载应用绑定的会话,因此恢复功能不受配置层修补影响。 diff --git a/packages/ui/tui/src/chat/resume.ts b/packages/ui/tui/src/chat/resume.ts index aa211f70ef..6105fb781d 100644 --- a/packages/ui/tui/src/chat/resume.ts +++ b/packages/ui/tui/src/chat/resume.ts @@ -1,16 +1,17 @@ /** * Session-resume sub-controller for the interactive chat channel: the - * `/resume` selector, one batch summary projection that tolerates a corrupt + * `/resume` selector, one metadata-plus-title scan that tolerates a corrupt * neighbor, the pre-handoff preflight, and the terminal handoff itself. * @module @deepseek-ai/dsh-tui/chat/resume */ +import { stat } from 'node:fs/promises' import type { TUI } from '@earendil-works/pi-tui' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import { errorChain } from '@deepseek-ai/dsh-llm' -import type { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-persistence' import type { - LogicalSessionSource, SessionQueryService, SessionRecord, } from '@deepseek-ai/dsh-session-query' @@ -66,51 +67,70 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro const workspaceLabel = (cwd: string | undefined): string => runtime.formatCwd?.(cwd) ?? formatCwd(cwd) - /** Summarize one record from a borrowed source, retaining only the record and derived scalars. */ + /** Summarize one record from metadata and its batch-folded title. */ const summarize = ( record: SessionRecord, - source: LogicalSessionSource, - providers: ReadonlySet, + title: string | undefined, + lastActivityAt: number | undefined, ): ResumeCandidate => summarizeResumeCandidate( record, - source, + title, + lastActivityAt, agent.session.id, agent.session.header.cwd, - providers, workspaceLabel, ) - /** The disabled fallback row for a session whose log cannot be summarized. */ - const unreadableCandidate = (record: SessionRecord, error: unknown): ResumeCandidate => ({ + /** The disabled fallback row for a session whose title read failed. */ + const unreadableCandidate = ( + record: SessionRecord, + lastActivityAt: number | undefined, + error: unknown, + ): ResumeCandidate => ({ record, title: 'Unreadable session', - lastActivityAt: record.header.createdAt, - lastTurn: 'log unavailable', + lastActivityAt: lastActivityAt ?? record.header.createdAt, currentWorkspace: record.header.cwd === agent.session.header.cwd, workspaceLabel: workspaceLabel(record.header.cwd), disabledReason: `session cannot be loaded: ${errorChain(error)}`, }) - /** Build one exact candidate from a live-preferred read that replay-validates a persisted log. */ - const readResumeCandidate = async ( - record: SessionRecord, - providers: ReadonlySet, - ): Promise => { + /** + * Metadata-only activity time: a live session's last in-memory event time, + * otherwise the persisted artifact's mtime. Never reads a log, so browsing + * cost stays independent of log size; any append (including bookkeeping) + * moves it. + */ + const lastActivityAt = async (record: SessionRecord): Promise => { + const live = ctx.sessions.get(record.header.id) + if (live !== undefined) return live.events.at(-1)?.time + const location = ctx.get('sessionPersistence')?.locate(record.header) + if (location === undefined) return undefined try { - const readQuery = sessionQuery() - /* v8 ignore start -- caller proves the optional service before mapping records */ - if (readQuery === undefined) throw new Error('session query is unavailable') - /* v8 ignore stop */ - const snapshot = await readQuery.readSession(record.header.id) - return summarize(record, { header: snapshot.session, events: snapshot.events }, providers) - } catch (error: unknown) { - return unreadableCandidate(record, error) + return (await stat(location.path)).mtimeMs + } catch { + // Only a just-deleted or never-materialized artifact fails stat; the row falls back to created-at. + return undefined } } + /** The latest logged provider/model route, for the preflight availability check. */ + const resumeRoute = (events: readonly SessionEvent[]): { provider: string; model: string } | undefined => { + const header = events.findLast(item => item.type === 'request/header') + if (header?.type === 'request/header') { + return { provider: header.data.header.config.provider, model: header.data.header.config.model } + } + const assistant = events.findLast(item => item.type === 'assistant/message') + return assistant?.type === 'assistant/message' + ? { provider: assistant.data.message.source.provider, model: assistant.data.message.source.model } + : undefined + } + /** * Re-read every mutable precondition immediately before terminal handoff and - * resolve the exact identity and workspace the host will re-exec into. + * resolve the exact identity and workspace the host will re-exec into. This + * is where the one chosen log is fully read, replay-validated, and checked + * for a currently-available route — the listing never does any of that. */ const preflightResume = async (sessionId: SessionId): Promise<{ id: SessionId; cwd: string }> => { const query = sessionQuery() @@ -121,17 +141,24 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro if (initialStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${initialStatus}).`) const record = (await query.listSessions()).find(candidate => candidate.header.id === sessionId) if (record === undefined) throw new Error(`Session "${sessionId}" is no longer available.`) - const candidate = await readResumeCandidate( - record, - new Set(ctx.llm.listProviders().map(provider => provider.id)), - ) + const candidate = summarize(record, undefined, undefined) if (candidate.disabledReason !== undefined) throw new Error(candidate.disabledReason) - const cwd = candidate.record.header.cwd + let events: readonly SessionEvent[] + try { + events = (await query.readSession(record.header.id)).events + } catch (error: unknown) { + throw new Error(`session cannot be loaded: ${errorChain(error)}`) + } + const route = resumeRoute(events) + if (route !== undefined && !ctx.llm.listProviders().some(provider => provider.id === route.provider)) { + throw new Error(`session is complete, but route is currently unavailable (${route.provider}/${route.model})`) + } + const cwd = record.header.cwd /* v8 ignore next -- summarizeResumeCandidate disables a cwd-less record, so the check above already rejected it */ if (cwd === undefined) throw new Error(`Session "${sessionId}" has no recorded workspace to resume in.`) const finalStatus = deps.agentStatus() if (finalStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${finalStatus}).`) - return { id: candidate.record.header.id, cwd } + return { id: record.header.id, cwd } } const handoffResume = async (candidate: ResumeCandidate, overlay: TuiOverlaySession): Promise => { @@ -235,30 +262,25 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro const scanStale = (): boolean => deps.isDisposed() || scan !== resumeScan || scanAbort.signal.aborted const scanCandidates = async (): Promise => { + // Every workspace in the store is listed; the picker owns the + // current-workspace/all-workspaces scope split over the whole set. const records = await listQuery.listSessions(scanAbort.signal) if (scanStale()) return - // Every workspace in the store is summarized; the picker owns the - // current-workspace/all-workspaces scope split over the whole set. - const providers = new Set(ctx.llm.listProviders().map(provider => provider.id)) - // One bounded batch projection over borrowed logs: unlike a - // per-candidate readSession, it lists persistence once and skips - // replay validation and log cloning, bounding memory by what each - // summary retains. A corrupt neighbor degrades to one disabled row. - const recordById = new Map(records.map(record => [record.header.id, record])) - const listedRecord = (id: SessionId): SessionRecord => { - const record = recordById.get(id) - /* v8 ignore next 2 -- projection ids come from this map; the corpus verifies each loaded header id */ - if (record === undefined) throw new Error(`resume scan returned unlisted session "${id}"`) - return record - } - const results = await listQuery.projectSessions( - records.map(record => record.header.id), - source => summarize(listedRecord(source.header.id), source, providers), - scanAbort.signal, - ) - const candidates = results.map(result => result.status === 'fulfilled' - ? result.value - : unreadableCandidate(listedRecord(result.sessionId), result.reason)) + // Rows need only metadata, an mtime, and the batch-folded title — the + // one per-log read the selector performs. A corrupt neighbor degrades + // to one disabled row. + const [titles, activity] = await Promise.all([ + listQuery.readTitleSnapshots(records.map(record => record.header.id), scanAbort.signal), + Promise.all(records.map(record => lastActivityAt(record))), + ]) + const candidates = records.map((record, index) => { + const title = titles[index] + /* v8 ignore next 2 -- readTitleSnapshots returns one result per unique listed id in input order */ + if (title === undefined || title.sessionId !== record.header.id) throw new Error(`resume scan misaligned at "${record.header.id}"`) + return title.status === 'fulfilled' + ? summarize(record, title.value.title?.title, activity[index]) + : unreadableCandidate(record, activity[index], title.reason) + }) candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt || a.record.header.id.localeCompare(b.record.header.id)) if (scanStale()) return @@ -266,9 +288,10 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro picker?.setCandidates(candidates) deps.requestRender() } - // One catch covers both stages, so a projection failure cannot strand - // the overlay on its loading placeholder; an aborted scan's rejection - // stays silent because the user already dismissed the picker. + // One catch covers listing, titles, and mtimes, so a scan failure + // cannot strand the overlay on its loading placeholder; an aborted + // scan's rejection stays silent because the user already dismissed the + // picker. void scanCandidates().catch((error: unknown) => { if (scanStale()) return void session.close() diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts index a5390a97b7..b6968d1acb 100644 --- a/packages/ui/tui/src/components/dialogs.ts +++ b/packages/ui/tui/src/components/dialogs.ts @@ -23,14 +23,8 @@ import { type AgentLlmTarget, } from '@deepseek-ai/dsh-agent' import type { LlmModelInfo, LlmModelReasoningInfo, ReasoningEffortId } from '@deepseek-ai/dsh-llm' -import { lastActivityTime } from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session' -import { foldGoal, type GoalPhase } from '@deepseek-ai/dsh-goal' -import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' -import type { - LogicalSessionSource, - SessionRecord, -} from '@deepseek-ai/dsh-session-query' +import type { SessionRecord } from '@deepseek-ai/dsh-session-query' import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction' import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, displayText, sanitizePastedText } from './text.ts' import { dialogSelectTheme, type Palette } from './theme.ts' @@ -432,97 +426,53 @@ export class ModelDialog implements Component { } } -/** The provider/model route recovered from a resume candidate's log. */ -export interface ResumeRoute { - provider: string - model: string -} - -/** A preflighted resume selector row summarizing one persisted session. */ +/** A resume selector row summarizing one session from metadata and its folded title. */ export interface ResumeCandidate { record: SessionRecord title: string + /** Last observed change: live last-event time or artifact mtime, falling back to creation. */ lastActivityAt: number - lastTurn: string /** Whether the session's workspace is the one the current session runs in, which selects the picker scope that lists it. */ currentWorkspace: boolean /** The session's own workspace as a prompt-style label; the all-workspaces scope shows it per row. */ workspaceLabel: string - route?: ResumeRoute - goalPhase?: GoalPhase disabledReason?: string } -function resumeTurnLabel(source: LogicalSessionSource): string { - const event = source.events.findLast(item => item.type === 'turn/end') - if (event === undefined) return 'no completed turn' - const reason = event.data.reason - switch (reason.kind) { - case 'completed': return `turn ${event.data.turn}: completed` - case 'aborted': return `turn ${event.data.turn}: cancelled` - case 'error': return `turn ${event.data.turn}: error` - case 'disposed': return `turn ${event.data.turn}: disposed` - case 'max-tokens': return `turn ${event.data.turn}: max tokens` - case 'interrupted': return `turn ${event.data.turn}: interrupted` - default: return `turn ${event.data.turn}: unknown result` - } -} - -function resumeRoute(source: LogicalSessionSource): ResumeRoute | undefined { - const header = source.events.findLast(item => item.type === 'request/header') - if (header?.type === 'request/header') { - return { provider: header.data.header.config.provider, model: header.data.header.config.model } - } - const assistant = source.events.findLast(item => item.type === 'assistant/message') - return assistant?.type === 'assistant/message' - ? { provider: assistant.data.message.source.provider, model: assistant.data.message.source.model } - : undefined -} - /** - * Build one resume selector row from a record and its borrowed log source, - * deriving the title, route, goal phase, workspace scope, and any reason the - * session cannot be resumed here. A workspace other than the current one is a - * scope, not a disabled reason: resuming it hands the process off into that - * directory. The result retains only the record and derived scalars, so a - * borrowed source stays valid for exactly this call. + * Build one resume selector row from a record, its batch-folded title, and a + * metadata-derived activity time, deriving the workspace scope and any reason + * the session cannot be resumed here. A workspace other than the current one + * is a scope, not a disabled reason: resuming it hands the process off into + * that directory. Rows carry no per-log detail beyond the title — route and + * replay validity are checked by the Enter-time preflight against the one + * chosen log. * @param record - The session record. - * @param source - The session's borrowed header and raw event log. + * @param title - The session's batch-folded title, absent for an untitled log. + * @param lastActivityAt - Metadata activity time; absent falls back to the header's creation time. * @param currentId - The current session id. * @param cwd - The CURRENT session's workspace, which decides the picker scope this row falls in. - * @param availableProviders - Providers registered in this runtime. * @param formatWorkspace - Renders THIS record's own cwd as its prompt-style label. * @returns The summarized resume candidate. */ export function summarizeResumeCandidate( record: SessionRecord, - source: LogicalSessionSource, + title: string | undefined, + lastActivityAt: number | undefined, currentId: SessionId, cwd: string | undefined, - availableProviders: ReadonlySet, formatWorkspace: (cwd: string | undefined) => string, ): ResumeCandidate { - const title = foldSessionTitle(source.events)?.title ?? 'Untitled session' - const route = resumeRoute(source) - const foldedGoal = foldGoal(source.events).goal let disabledReason: string | undefined if (record.header.id === currentId) disabledReason = 'current session' else if (record.live) disabledReason = 'session is already live in this runtime' else if (record.header.cwd === undefined) disabledReason = 'session has no recorded workspace' - else if (route !== undefined && !availableProviders.has(route.provider)) { - disabledReason = `session is complete, but route is currently unavailable (${route.provider}/${route.model})` - } return { record, - title, - // Excludes a prior pickup's boundary, or every browsed session floats up. - lastActivityAt: lastActivityTime(source.events) ?? source.header.createdAt, - lastTurn: resumeTurnLabel(source), + title: title ?? 'Untitled session', + lastActivityAt: lastActivityAt ?? record.header.createdAt, currentWorkspace: record.header.cwd === cwd, workspaceLabel: formatWorkspace(record.header.cwd), - ...route === undefined ? {} : { route }, - /* v8 ignore next -- goal-bearing resume records are covered by the goal/session integration surface. */ - ...foldedGoal === undefined ? {} : { goalPhase: foldedGoal.phase }, ...disabledReason === undefined ? {} : { disabledReason }, } } @@ -601,7 +551,7 @@ export class ResumePicker implements Component, Focusable { private visibleCandidateCount(): number { // The all-workspaces scope adds a per-row workspace line, so a row costs // one more terminal row there than in the single-workspace scope. - const rowHeight = this.scope === 'all' ? 5 : 4 + const rowHeight = this.scope === 'all' ? 4 : 3 const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / rowHeight)) return Math.min(this.maxVisible, candidateBudget) } @@ -748,11 +698,7 @@ export class ResumePicker implements Component, Focusable { ].filter((value): value is string => value !== undefined).join(' · ') const lead = `${active ? '❯' : ' '} ${displayText(candidate.title)}` push(active ? this.palette.bold(this.palette.accent(lead)) : lead) - const route = candidate.route === undefined ? 'route unavailable' : `${candidate.route.provider}/${candidate.route.model}` - /* v8 ignore next -- only goal-bearing resume records add this integration-owned suffix. */ - const goal = candidate.goalPhase === undefined ? '' : ` · goal ${candidate.goalPhase}` - push(this.palette.dim(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`)) - push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`)) + push(this.palette.dim(` ${new Date(candidate.lastActivityAt).toISOString()} · ${status} · ${displayText(candidate.record.header.id)}`)) // Only the all-workspaces scope mixes directories, so the per-row // workspace is redundant in the scope that already names one. if (this.scope === 'all') { diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index a62b695b72..3f10d6cfe3 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -69,6 +69,8 @@ export interface TuiHarnessOptions { sessionPersistence?: { list(): Promise load?(id: ReturnType): Promise<{ meta: SessionHeader; events: Session['events'] }> + /** Per-session artifact location for mtime-based activity; defaults to none. */ + locate?(meta: SessionHeader): { kind: string; path: string } | undefined } handoffResume?: TuiRuntime['handoffResume'] /** Host-supplied exit line; absent exercises the no-message path. */ @@ -156,7 +158,7 @@ export async function createTuiTestHarness undefined, + locate: (meta: SessionHeader) => persistence.locate?.(meta), create: () => Promise.resolve(), append: () => Promise.resolve(), load: persistence.load === undefined diff --git a/packages/ui/tui/tests/snapshots/resume-sessions-all-workspaces.expected.txt b/packages/ui/tui/tests/snapshots/resume-sessions-all-workspaces.expected.txt index 63ca211c89..9ab0788461 100644 --- a/packages/ui/tui/tests/snapshots/resume-sessions-all-workspaces.expected.txt +++ b/packages/ui/tui/tests/snapshots/resume-sessions-all-workspaces.expected.txt @@ -22,28 +22,25 @@ buffer 8| " " 9| " ❯ Untitled session " style 2-19 fg=bright-magenta bold -10| " 2026-07-23T08:00:00.000Z · no completed turn · route unavailable " - style 2-67 dim -11| " current · live · main-session " - style 2-32 dim -12| " workspace /workspace/project " +10| " 2026-07-23T08:00:00.000Z · current · live · main-session " + style 2-59 dim +11| " workspace /workspace/project " style 2-31 dim -13| " unavailable: current session " +12| " unavailable: current session " style 2-31 fg=yellow -14| " Other workspace work " -15| " 2024-02-02T00:00:08.000Z · turn 1: completed · deepseek-official/deepseek-v4-pro " - style 2-83 dim -16| " persisted · elsewhere-session " - style 2-32 dim -17| " workspace /workspace/other " +13| " Other workspace work " +14| " 2024-02-02T00:00:00.000Z · persisted · elsewhere-session " + style 2-59 dim +15| " workspace /workspace/other " style 2-29 dim -18| " Resume selector design " -19| " 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek-official/deepseek-v4-pro " - style 2-83 dim -20| " persisted · earlier-session " - style 2-30 dim -21| " workspace /workspace/project " +16| " Resume selector design " +17| " 2024-01-01T00:00:00.000Z · persisted · earlier-session " + style 2-57 dim +18| " workspace /workspace/project " style 2-31 dim +19| " " +20| " " +21| " " 22| " " 23| " " 24| " " diff --git a/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt b/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt index 46452aebdb..6f3e87ed43 100644 --- a/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt +++ b/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt @@ -22,17 +22,15 @@ buffer 8| " " 9| " ❯ Untitled session " style 2-19 fg=bright-magenta bold -10| " 2026-07-23T08:00:00.000Z · no completed turn · route unavailable " - style 2-67 dim -11| " current · live · main-session " - style 2-32 dim -12| " unavailable: current session " +10| " 2026-07-23T08:00:00.000Z · current · live · main-session " + style 2-59 dim +11| " unavailable: current session " style 2-31 fg=yellow -13| " Resume selector design " -14| " 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek-official/deepseek-v4-pro " - style 2-83 dim -15| " persisted · earlier-session " - style 2-30 dim +12| " Resume selector design " +13| " 2024-01-01T00:00:00.000Z · persisted · earlier-session " + style 2-57 dim +14| " " +15| " " 16| " " 17| " " 18| " " diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 91d1a67d78..13ddadb879 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -879,12 +879,12 @@ describe('TUI terminal-state snapshots', () => { { type: 'step/end', seq: 5, time: Date.parse(`${day}T00:00:06Z`), data: { turn: 1, step: 1 } }, { type: 'turn/end', seq: 6, time: Date.parse(`${day}T00:00:07Z`), data: { turn: 1, reason: { kind: 'completed' } } }, { type: 'session/title', seq: 7, time: Date.parse(`${day}T00:00:08Z`), data: { title, messageSeqs: [1], source: { kind: 'fallback' } } }, - // A prior pickup, dated well after the work: the picker must still - // show the work's date, not the pickup's. - { type: 'session/end-seed', seq: 8, time: Date.parse('2026-07-23T07:59:00.000Z'), data: {} }, ], }) const listGate = Promise.withResolvers() + // Rows show metadata activity (here the created-at fallback: the fake + // store locates no per-session artifact to stat) plus each log's one + // batch-folded title; nothing else is read from the logs. const harness = await setupSnapshot({ sessionPersistence: { list: async () => { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 03ca90bca2..e780f88624 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { describe, expect, it, vi } from 'vitest' @@ -280,16 +280,19 @@ describe('goodbye message and /resume', () => { { type: 'turn/end', seq: 6, time: time + 6, data: { turn: 1, reason } }, { type: 'session/title', seq: 7, time: time + 7, data: { title, messageSeqs: [1], source: { kind: 'fallback' } } }, ] - /** Derive the selector's batch projection from a fake per-session readSession. */ - const projectViaReadSession = ( + /** Derive the selector's batch title read from a fake per-session readSession. */ + const titlesViaReadSession = ( readSession: (id: SessionId) => Promise<{ session: SessionHeader; events: SessionEvent[] }>, - ) => ( - ids: readonly SessionId[], - project: (source: { header: SessionHeader; events: readonly SessionEvent[] }) => unknown, - ) => Promise.all(ids.map(async (sessionId) => { + ) => (ids: readonly SessionId[]) => Promise.all(ids.map(async (sessionId) => { try { const snapshot = await readSession(sessionId) - return { sessionId, status: 'fulfilled', value: project({ header: snapshot.session, events: snapshot.events }) } + const titleEvent = snapshot.events.findLast(event => event.type === 'session/title') + const title = titleEvent?.type === 'session/title' ? { title: titleEvent.data.title } : undefined + return { + sessionId, + status: 'fulfilled', + value: { session: snapshot.session, ...title === undefined ? {} : { title } }, + } } catch (reason) { return { sessionId, status: 'rejected', reason } } @@ -451,7 +454,7 @@ describe('goodbye message and /resume', () => { result.terminal.send('\x1b[6~') await tick() const rendered = result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session')) - expect(rendered).toContain('❯ Paged 3') + expect(rendered).toContain('❯ Paged 5') result.terminal.send('\x1b[5~') await tick() expect(result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session'))) @@ -484,26 +487,53 @@ describe('goodbye message and /resume', () => { await dispose(result) }) - it.each([ - [{ kind: 'aborted' }, 'cancelled'], - [{ kind: 'error', step: 1, message: 'failed' }, 'error'], - [{ kind: 'disposed' }, 'disposed'], - [{ kind: 'max-tokens' }, 'max tokens'], - [{ kind: 'interrupted' }, 'interrupted'], - [{ kind: 'future-result' } as unknown as TurnEndReason, 'unknown result'], - ] as const)('renders the last turn result %s', async (reason, label) => { - const target = header(`turn-${label}`, 10, '/workspace') + it('orders rows by artifact mtime without reading logs for the timestamp', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-resume-mtime-')) + const stale = join(dir, 'stale.log') + const fresh = join(dir, 'fresh.log') + await writeFile(stale, 'x') + await writeFile(fresh, 'x') + await utimes(stale, new Date(1000), new Date(60_000)) + await utimes(fresh, new Date(1000), new Date(120_000)) + // Creation order contradicts mtime order, so the sort proves its source. + const createdLate = header('created-late-touched-early', 50, '/workspace') + const createdEarly = header('created-early-touched-late', 40, '/workspace') + const gone = header('artifact-gone', 30, '/workspace') + const goneTwin = header('artifact-gone-twin', 30, '/workspace') + const paths = new Map([ + [createdLate.id, stale], + [createdEarly.id, fresh], + [gone.id, join(dir, 'missing.log')], + [goneTwin.id, join(dir, 'missing-twin.log')], + ]) + const titles = new Map([ + [createdLate.id, 'Touched early'], + [createdEarly.id, 'Touched late'], + [gone.id, 'Artifact gone'], + [goneTwin.id, 'Artifact gone twin'], + ]) const result = await setup({ cwd: '/workspace', sessionPersistence: { - list: async () => [target], - load: async () => ({ meta: target, events: resumeEvents(`Turn ${label}`, 'deepseek-official', 100, reason) }), + list: async () => [createdLate, createdEarly, gone, goneTwin], + load: async id => ({ + meta: [createdLate, createdEarly, gone, goneTwin].find(target => target.id === id)!, + events: resumeEvents(titles.get(id)!), + }), + locate: meta => ({ kind: 'jsonl', path: paths.get(meta.id)! }), }, }) result.terminal.send('/resume') result.terminal.send('\r') await tick(); await tick() - expect(result.terminal.output).toContain(`turn 1: ${label}`) + const rendered = result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session')) + expect(rendered).toContain(new Date(120_000).toISOString()) + expect(rendered.indexOf('Touched late')).toBeLessThan(rendered.indexOf('Touched early')) + // A missing artifact falls back to the header's creation time; equal + // times tie-break by id. + expect(rendered).toContain(new Date(gone.createdAt).toISOString()) + expect(rendered.indexOf('artifact-gone')).toBeLessThan(rendered.indexOf('artifact-gone-twin')) + await rm(dir, { recursive: true, force: true }) await dispose(result) }) @@ -537,7 +567,7 @@ describe('goodbye message and /resume', () => { queryCtx = child child.provide('sessionQuery', { listSessions: async () => { listCalls++; return [] }, - projectSessions: async () => [], + readTitleSnapshots: async () => [], } as never) }, }) @@ -579,7 +609,7 @@ describe('goodbye message and /resume', () => { persisted: true, }]), readSession, - projectSessions: projectViaReadSession(readSession), + readTitleSnapshots: titlesViaReadSession(readSession), } as never) }, }) @@ -617,7 +647,7 @@ describe('goodbye message and /resume', () => { ctx.provide('tools', { get: () => undefined } as never) ctx.provide('sessionQuery', { listSessions: () => ++calls === 1 ? first.promise : Promise.resolve([]), - projectSessions: async () => [], + readTitleSnapshots: async () => [], } as never) }, }) @@ -686,7 +716,7 @@ describe('goodbye message and /resume', () => { ctx.provide('tools', { get: () => undefined } as never) ctx.provide('sessionQuery', { listSessions: (signal?: AbortSignal) => { scanSignal = signal; return listing.promise }, - projectSessions: async () => { projections += 1; return [] }, + readTitleSnapshots: async () => { projections += 1; return [] }, } as never) }, }) @@ -698,7 +728,7 @@ describe('goodbye message and /resume', () => { await tick() expect(scanSignal?.aborted).toBe(true) // A signal-ignoring backend can still fulfill after dismissal: the stale - // scan must neither project nor report. + // scan must neither read titles nor report. listing.resolve([]) await tick() expect(projections).toBe(0) @@ -706,14 +736,14 @@ describe('goodbye message and /resume', () => { await dispose(result) }) - it('drops a projection that settles after the picker was dismissed', async () => { + it('drops a title read that settles after the picker was dismissed', async () => { const projecting = Promise.withResolvers() const result = await setup({ async configureContext(ctx) { ctx.provide('tools', { get: () => undefined } as never) ctx.provide('sessionQuery', { listSessions: async () => [], - projectSessions: () => projecting.promise, + readTitleSnapshots: () => projecting.promise, } as never) }, }) @@ -730,20 +760,20 @@ describe('goodbye message and /resume', () => { }) it('closes the loading picker and reports a scan that fails after listing', async () => { - const target = header('projection-explodes', 10, '/workspace') + const target = header('titles-explode', 10, '/workspace') const result = await setup({ async configureContext(ctx) { ctx.provide('tools', { get: () => undefined } as never) ctx.provide('sessionQuery', { listSessions: () => Promise.resolve([{ header: target, live: false, persisted: true }]), - projectSessions: () => Promise.reject(new Error('projection exploded')), + readTitleSnapshots: () => Promise.reject(new Error('titles exploded')), } as never) }, }) result.terminal.send('/resume') result.terminal.send('\r') await tick(); await tick() - expect(result.terminal.output).toContain('Resume session scan failed: projection exploded') + expect(result.terminal.output).toContain('Resume session scan failed: titles exploded') expect(result.terminal.stopped).toBe(0) await dispose(result) }) @@ -762,7 +792,7 @@ describe('goodbye message and /resume', () => { ctx.provide('sessionQuery', { listSessions: () => listing.promise, readSession, - projectSessions: projectViaReadSession(readSession), + readTitleSnapshots: titlesViaReadSession(readSession), } as never) }, }) @@ -818,11 +848,12 @@ describe('goodbye message and /resume', () => { result.terminal.send('\r') await tick(); await tick() expect(result.terminal.output).toContain('Missing adapter') - expect(result.terminal.output).toContain('absent-provider/model-1') + // Rows carry no route: availability surfaces only at Enter-time preflight. + expect(result.terminal.output).not.toContain('absent-provider/model-1') expect(result.terminal.output).toContain('Unreadable session') result.terminal.send('Missing adapter') result.terminal.send('\r') - await tick() + await tick(); await tick() expect(result.terminal.output).toContain('route is currently unavailable') expect(result.terminal.stopped).toBe(0) await dispose(result) @@ -847,7 +878,7 @@ describe('goodbye message and /resume', () => { persisted: true, }]), readSession, - projectSessions: projectViaReadSession(readSession), + readTitleSnapshots: titlesViaReadSession(readSession), } as never) }, }) @@ -862,10 +893,45 @@ describe('goodbye message and /resume', () => { await dispose(result) }) + it('rechecks record liveness at preflight rather than trusting the listed row', async () => { + const target = header('turns-live', 10, '/workspace') + const handoff = vi.fn>() + let listings = 0 + const result = await setup({ + cwd: '/workspace', + handoffResume: handoff, + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + const readSession = () => Promise.resolve({ + session: target, + events: resumeEvents('Turns live'), + }) + ctx.provide('sessionQuery', { + listSessions: () => Promise.resolve([{ + header: target, + live: ++listings > 1, + persisted: true, + }]), + readSession, + readTitleSnapshots: titlesViaReadSession(readSession), + } as never) + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Turns live') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('session is already live in this runtime') + expect(handoff).not.toHaveBeenCalled() + await dispose(result) + }) + it('falls back to assistant provenance and header creation time for sparse logs', async () => { const assistantOnly = header('assistant-route', 20, '/workspace') const empty = header('empty-log', 10, '/workspace') - const events = resumeEvents('Assistant route', 'deepseek-official') + const events = resumeEvents('Assistant route', 'absent-provider') .filter(event => event.type !== 'request/header') .map((event, seq) => ({ ...event, seq })) as SessionEvent[] const result = await setup({ @@ -880,8 +946,23 @@ describe('goodbye message and /resume', () => { result.terminal.send('/resume') result.terminal.send('\r') await tick(); await tick() - expect(result.terminal.output).toContain('deepseek-official/model-1') + // Without a persisted artifact to stat, listing falls back to creation time. expect(result.terminal.output).toContain(new Date(empty.createdAt).toISOString()) + // The preflight route fold falls back to assistant provenance when the + // log carries no request header. + result.terminal.send('Assistant route') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('route is currently unavailable') + // The failed preflight closed the picker; reopen and pick the routeless + // log, which passes the route check — only the absent host stops it. + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('empty-log') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('cannot hand it off in place') await dispose(result) }) @@ -975,7 +1056,7 @@ describe('goodbye message and /resume', () => { ctx.provide('sessionQuery', { listSessions: () => ++listings === 1 ? Promise.resolve([record]) : secondListing.promise, readSession, - projectSessions: projectViaReadSession(readSession), + readTitleSnapshots: titlesViaReadSession(readSession), } as never) }, }) @@ -1013,7 +1094,7 @@ describe('goodbye message and /resume', () => { persisted: true, }]), readSession, - projectSessions: projectViaReadSession(readSession), + readTitleSnapshots: titlesViaReadSession(readSession), } as never) }, }) @@ -1409,10 +1490,6 @@ describe('pi-tui chat lifecycle and transcript', () => { }) expect(result.terminal.output).toContain('Goal restored (active) with automatic continuation disarmed') expect(result.terminal.output).toContain('/goal resume') - result.terminal.send('/resume') - result.terminal.send('\r') - await tick(); await tick() - expect(result.terminal.output).toContain('goal active') await dispose(result) }) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 98331f5568..2ec0b6a4e7 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -55,8 +55,6 @@ export const LINK_MAP: Readonly> = { SessionEvent: 'core.md', SessionId: 'core.md', SessionStartSource: 'core.md', - LogicalProjectionResult: 'session-query.md', - LogicalSessionSource: 'session-query.md', SessionLogSnapshot: 'session-query.md', SessionSurfaceSnapshot: 'session-query.md', ApprovalOutcome: 'approval.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 80b70a4e3e..3d1fcc8f9d 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -459,16 +459,6 @@ "symbol": "SessionTitleObservationResult", "source": "packages/session-query/session-query/src/types.ts" }, - { - "doc": "docs/core-data-structures/session-query.md", - "symbol": "LogicalSessionSource", - "source": "packages/session-query/session-query/src/corpus.ts" - }, - { - "doc": "docs/core-data-structures/session-query.md", - "symbol": "LogicalProjectionResult", - "source": "packages/session-query/session-query/src/corpus.ts" - }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventRecord", From 4c8b47f3c6252b83f190218ef97b1dfd29d556c5 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 3 Aug 2026 17:00:33 +0800 Subject: [PATCH 40/52] test(web): refresh markdown image golden --- apps/web/tests/snapshots/markdown-images/ui.expected.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/tests/snapshots/markdown-images/ui.expected.md b/apps/web/tests/snapshots/markdown-images/ui.expected.md index 76e01397c2..58b72e0d65 100644 --- a/apps/web/tests/snapshots/markdown-images/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-images/ui.expected.md @@ -7,8 +7,9 @@ - text: Show the Markdown image policy. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - heading "Markdown images" [level=2] - paragraph: - img "Remote test image" From a3d897359f8f5b90a3695f34d7630534fb998d65 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:11:36 +0800 Subject: [PATCH 41/52] test(web): refresh Markdown image golden for the fork-eligibility gate --- apps/web/tests/snapshots/markdown-images/ui.expected.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/tests/snapshots/markdown-images/ui.expected.md b/apps/web/tests/snapshots/markdown-images/ui.expected.md index 76e01397c2..58b72e0d65 100644 --- a/apps/web/tests/snapshots/markdown-images/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-images/ui.expected.md @@ -7,8 +7,9 @@ - text: Show the Markdown image policy. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - heading "Markdown images" [level=2] - paragraph: - img "Remote test image" From 553fb9a119b0518c144266a357aecadb79796b59 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:17:41 +0800 Subject: [PATCH 42/52] fix: test --- apps/web/tests/snapshots/markdown-images/ui.expected.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/tests/snapshots/markdown-images/ui.expected.md b/apps/web/tests/snapshots/markdown-images/ui.expected.md index 76e01397c2..58b72e0d65 100644 --- a/apps/web/tests/snapshots/markdown-images/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-images/ui.expected.md @@ -7,8 +7,9 @@ - text: Show the Markdown image policy. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - heading "Markdown images" [level=2] - paragraph: - img "Remote test image" From 3b58ed65b7110f073f6f00c97a830a6bd1c1bc7e Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 3 Aug 2026 17:15:04 +0800 Subject: [PATCH 43/52] feat(tui): wrap and page question dialogs --- ...24-tui-question-dialog-multiline.i18n.yaml | 6 + ...026-07-24-tui-question-dialog-multiline.md | 41 ++ ...-07-24-tui-question-dialog-multiline.zh.md | 41 ++ docs/cordis-catalog/services.md | 2 +- packages/ui/tui/README.i18n.yaml | 4 +- packages/ui/tui/README.md | 6 +- packages/ui/tui/README.zh.md | 6 +- packages/ui/tui/src/chat/questions.ts | 10 +- packages/ui/tui/src/components/dialogs.ts | 395 ++++++++++++-- .../ui/tui/src/extension/overlay-manager.ts | 24 +- packages/ui/tui/src/index.ts | 62 ++- .../question-dialog-detail-paged.expected.txt | 39 ++ .../question-dialog-paged.expected.txt | 39 ++ ...question-dialog-single-option.expected.txt | 30 +- .../question-dialog-validation.expected.txt | 72 +-- .../snapshots/question-dialog.expected.txt | 66 +-- .../snapshots/untrusted-controls.expected.txt | 39 +- packages/ui/tui/tests/tui.snapshot.ts | 16 +- packages/ui/tui/tests/tui.spec.ts | 511 ++++++++++++++++++ 19 files changed, 1231 insertions(+), 178 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.md create mode 100644 .agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.zh.md create mode 100644 packages/ui/tui/tests/snapshots/question-dialog-detail-paged.expected.txt create mode 100644 packages/ui/tui/tests/snapshots/question-dialog-paged.expected.txt diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.i18n.yaml new file mode 100644 index 0000000000..8cc4d64238 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.md +2026-07-24-tui-question-dialog-multiline.md: fc6e9bceeee4abc46a69a23124d09fcd4f3c7224 +2026-07-24-tui-question-dialog-multiline.zh.md: a56821921bad1016009687bde63eae5f4d893cdf diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.md b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.md new file mode 100644 index 0000000000..fc6e9bceee --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.md @@ -0,0 +1,41 @@ +# Agent Note: TUI QuestionDialog renders options across multiple lines + +Status: implemented + +English | [中文](2026-07-24-tui-question-dialog-multiline.zh.md) + +## Problem + +`ctx.userInteraction.ask()` must keep question text, supporting `detail`, option labels, descriptions, validation, and controls readable inside configured width and height bounds. The question panel also belongs directly above the editor: placing it at the terminal edge separates the pending decision from both the transcript that prompted it and the input that follows it. + +## Decision + +The TUI renders a pending question as an inline modal between the transcript/status area and the editor while retaining the shared FIFO with model and plugin overlays: + +- `InlineModalComponent` applies `questionDialogWidth` and `questionDialogMaxHeight` inside the normal component flow. The effective question height is additionally clamped to the current viewport after reserving the editor, so the editor remains below the question during resize. +- `renderOptionBlock` wraps each label beneath its cursor/number prefix and renders the muted description on separately wrapped, equally indented lines. The progress header, question, custom-answer hint, validation text, and final rows are width-bounded as well; the final ellipsis clamp is only a safety boundary for prefixes or other indivisible content. The explicit `↑ N lines hidden` fallback is reserved for a viewport below the configured minimum, where the whole semantic layout cannot fit. +- When question text or `detail` exceeds the header allocation, the header becomes a paged line viewport with its own `… lines A-B/N • PgUp/PgDn` status row. Page Up and Page Down traverse both line viewports: forward navigation exhausts the header/detail pages before entering oversized selected-option pages, and backward navigation reverses that order. This keeps plan-review detail reachable rather than leaving it behind the height clamp. +- The option-line budget subtracts padding, header, position, and footer rows before `windowBlocks` runs. The window obeys both `maxQuestionOptions` and the remaining row budget, keeps the selected option visible, and renders omitted options as `↑ N more` / `↓ N more` markers. If fixed chrome would leave fewer than four option rows, the compact header becomes the line pager so selected content, paging status, and both option markers still fit. +- When one selected block exceeds its allocation, it becomes a line viewport with a `lines A-B/N • PgUp/PgDn` status row. Page Up and Page Down expose every wrapped line without allowing the block to hide the option markers, validation, or controls. + +Package tests pin count and height bounds, header and selected-block paging order, narrow-width wrapping, selection behavior, and placement relative to retained editor input. Semantic TUI snapshots pin the assembled terminal layout, header/detail and selected-option page transitions, and validation state. + +## Alternatives considered + +**Ellipsis-only horizontal truncation.** Keeping one option per row would signal lost text without making the description readable and would not address vertical bounds. The implementation wraps readable content and retains an ellipsis only as a final safety boundary. + +**Wrap the combined label and description.** A composite row couples their widths, so either side can starve the other. Separate lines keep both widths predictable. + +**Keep the question as a bottom-edge overlay.** A terminal-edge anchor can place the panel after the editor or cover lower chrome, depending on transcript and viewport height. The inline modal preserves ordering while the modal manager retains focus and FIFO ownership. + +**Push the bounds into pi-tui.** Generic overlay slicing cannot identify option boundaries, selected content, controls, or the inline editor relationship. The owning dialog therefore applies semantic count, row, and paging rules. + +**Use only the option-count cap.** `maxQuestionOptions` remains a public count bound, but it cannot contain wrapped blocks by itself. The dialog enforces the count and row bounds together. + +## Consequences + +- Descriptions consume additional rows, so fewer options can be visible than `maxQuestionOptions`; markers state the omitted option counts. +- Long question text and plan-review detail remain reachable inside a height-bounded panel, at the cost of sharing Page Up and Page Down with selected-option paging. +- An oversized selected block reserves one status row and requires Page Up or Page Down to read beyond the current line page. +- The inline question can displace older transcript rows from a short viewport. Below the configured minimum height, the final fallback can collapse upper rows behind an explicit hidden-line marker so the input controls and editor remain available. +- The model-facing schema, selected labels, abort/cancel behavior, and ACP elicitation path are unchanged. diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.zh.md b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.zh.md new file mode 100644 index 0000000000..a56821921b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.zh.md @@ -0,0 +1,41 @@ +# Agent Note: TUI QuestionDialog 以多行方式渲染选项 + +Status: implemented + +[English](2026-07-24-tui-question-dialog-multiline.md) | 中文 + +## 问题 + +`ctx.userInteraction.ask()` 必须确保问题正文、`detail` 补充内容、选项标签、描述、校验信息和控件在已配置的宽度与高度边界内均可读。问题面板也直接位于编辑器上方:若将其置于终端边缘,待处理决策就会同时脱离触发该决策的 transcript(文本记录)和后续输入。 + +## 决策 + +TUI 将待处理问题渲染为位于 transcript/状态区域与编辑器之间的内联模态框,同时仍与模型浮层和插件浮层共享 FIFO: + +- `InlineModalComponent` 在正常组件流内应用 `questionDialogWidth` 和 `questionDialogMaxHeight`。系统在为编辑器预留空间后,还会根据当前视口限制问题的实际高度,因此调整窗口大小时,编辑器仍位于问题下方。 +- `renderOptionBlock` 将每个标签换行到光标/编号前缀下方,并在另行换行且缩进相同的行上渲染弱化的描述。进度标题、问题、自定义答案提示、校验文本和末尾行也受宽度边界约束;最终的省略号截断仅作为前缀或其他不可拆分内容的安全边界。明确的 `↑ N lines hidden` 回退仅用于低于已配置最小值、无法容纳完整语义布局的视口。 +- 当问题正文或 `detail` 超出头部分配的空间时,头部会成为带有独立 `… lines A-B/N • PgUp/PgDn` 状态行的分页行视口。Page Up 和 Page Down 会遍历这两个行视口:向前导航先翻完问题正文/`detail` 页面,再进入超大选中选项页面;向后导航则采用相反顺序。这样可确保计划评审的 `detail` 内容始终可达,而不会被高度边界挡住。 +- 在 `windowBlocks` 运行前,选项行预算会扣除内边距、标题行、位置行和页脚行。窗口同时遵守 `maxQuestionOptions` 和剩余行预算,保持选中项可见,并将省略的选项渲染为 `↑ N more`/`↓ N more` 标记。若固定界面元素会使选项行少于四行,紧凑头部会转为行分页器,从而容纳选中内容、分页状态和上下两个选项标记。 +- 当一个选中块超出分配空间时,它会成为带有 `lines A-B/N • PgUp/PgDn` 状态行的行视口。Page Up 和 Page Down 可展示每一行已换行内容,同时防止该块遮住选项标记、校验信息或控件。 + +包(package)测试固定数量和高度边界、头部与选中块的分页顺序、窄宽度换行、选择行为,以及问题相对于保留的编辑器输入的位置。语义 TUI 快照固定组装后的终端布局、头部/详情与选中选项的分页转换,以及校验状态。 + +## 备选方案 + +**仅用省略号进行横向截断。** 保持每个选项占一行,只能提示文本有所丢失,无法使描述变得可读,也无法处理纵向边界。该实现会对可读内容换行,仅将省略号保留为最终安全边界。 + +**将标签与描述合并后换行。** 组合行会将两者的宽度耦合在一起,任一方都可能挤占另一方的空间。分行渲染可使二者的宽度保持可预测。 + +**将问题保留为终端底边浮层。** 根据 transcript 和视口高度,锚定在终端边缘的面板可能出现在编辑器之后,也可能遮盖下方界面元素。内联模态框可保留顺序,同时由模态管理器继续负责焦点和 FIFO 所有权。 + +**将边界处理下推至 pi-tui。** 通用浮层切片无法识别选项边界、选中内容、控件或内联编辑器关系。因此,负责该语义的对话框会应用数量、行数和分页规则。 + +**仅使用选项数量上限。** `maxQuestionOptions` 仍是公开的数量边界,但仅靠它无法容纳已换行的块。对话框会同时执行数量边界和行数边界。 + +## 后果 + +- 描述会占用额外行,因此可见选项数可能少于 `maxQuestionOptions`;标记会说明省略的选项数量。 +- 较长的问题正文和计划评审 `detail` 在受高度约束的面板内仍然可达,代价是 Page Up 和 Page Down 需要与选中选项分页共用。 +- 超出空间的选中块会预留一行状态信息;若要阅读当前页面之外的行,必须使用 Page Up 或 Page Down。 +- 在较矮的视口内,内联问题可能将较早的 transcript 行挤出可见区域。低于已配置最小高度时,最终回退可能将上部行折叠到明确的隐藏行标记之后,从而让输入控件和编辑器仍然可用。 +- 面向模型的 schema、选中的标签、中止/取消行为,以及 ACP(Agent Client Protocol)的 elicitation 路径均保持不变。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 8e76c1c8de..b518f37876 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2451,7 +2451,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession ``` -Source: [`packages/ui/tui/src/index.ts:244`](../../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:245`](../../packages/ui/tui/src/index.ts) ## `ctx.typert` — `TypertRegistry` diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index 246e459e57..a2487c1b20 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/tui/README.md -README.md: 60e42a64305931a6bf93a470f851f662be33e2bb -README.zh.md: fc12b75a1e320103a1d4cc70cc00adfeb639a3d9 +README.md: e441cf949d15ea76f93efc89f970aa2096e83153 +README.zh.md: cc98ea4405ff57b56059e283ecf1b9c8d4d95b91 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 60e42a6430..e441cf949d 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -12,7 +12,7 @@ This package owns interactive terminal presentation and input only. It injects ` After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme (including terminal-safe DeepSeek `brand` treatment), display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives. -The TUI rebuilds resumed history from the append-origin session events, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes ``. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. A surface replacement never rewrites the rendered transcript: the conversation it shadows stays readable, and a landed compaction checkpoint adds one dim `… earlier context was compacted …` marker at its log position, so the terminal reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies — a pruned tool result, a regenerated assistant message — render nothing. +The TUI rebuilds resumed history from the append-origin session events, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions inline between the transcript/status area and the editor. The question panel shows progress, numbered options, wrapped labels, and separately indented descriptions; it obeys both `maxQuestionOptions` and `questionDialogMaxHeight`, marks hidden options with `↑ N more` / `↓ N more`, and uses Page Up / Page Down to page long question/detail content before an individually oversized selected block while keeping the editor visible. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes ``. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. A surface replacement never rewrites the rendered transcript: the conversation it shadows stays readable, and a landed compaction checkpoint adds one dim `… earlier context was compacted …` marker at its log position, so the terminal reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies — a pruned tool result, a regenerated assistant message — render nothing. An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label differs from the session's host directory. The override changes only the footer label; tools continue to use the session `cwd`. @@ -51,11 +51,11 @@ A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY | `showReasoning` | `true` | Render reasoning blocks | | `maxToolOutputLines` | `6` | Output lines retained across a collapsed tool card's head/tail preview | | `maxDiffEditLength` | `1000` | Maximum added and removed lines explored for an exact diff before whole-side fallback | -| `maxQuestionOptions` | `8` | Visible options in a question panel | +| `maxQuestionOptions` | `8` | Maximum option blocks visible at once; the row bound may reduce this further | | `maxModelOptions` | `8` | Visible models in the model selector | | `maxResumeOptions` | `8` | Visible sessions in the resume selector | | `questionDialogWidth` | `200` | Question-panel width in columns, clamped to the terminal | -| `questionDialogMaxHeight` | `20` | Question-panel maximum rows | +| `questionDialogMaxHeight` | `20` | Maximum question-panel rows, further bounded to retain the editor | | `modelDialogWidth` | `76` | Model-selector width in columns | | `modelDialogMaxHeight` | `20` | Model-selector maximum rows | | `detailsDialogWidth` | `72` | Transcript-details selector width in columns | diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index fc12b75a1e..cc98ea4405 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -12,7 +12,7 @@ DeepSeek Harness agent(智能体)的交互式终端入口,基于 [`@earend 终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题(包括终端安全的 DeepSeek `brand` 样式)、显示文本转义、重绘、关闭和生命周期信号,但不公开 pi-tui 树、终端、焦点控制器或 overlay 句柄。插件 overlay、模型选择器和用户问题共用一个 FIFO 模态队列。每个请求都是调用方插件 fiber 的 effect,因此卸载会移除排队工作,或在清理结算前关闭可见工作;终端关闭会先卸载依赖项,再停止 pi-tui。Overlay 状态不会记录或回放。组件代码受信任,可以渲染 ANSI 样式,但必须通过 `host.display()` 处理不受信任文本。[交互式扩展 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md)持有该边界和未采用的替代方案。 -TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 ``。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型,以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换从不重写已渲染的 transcript:被它遮蔽的对话仍可阅读,而已落地的压缩(compaction)检查点会在其日志位置添加一行暗色 `… earlier context was compacted …` 标记,因此终端报告的是模型从何处起不再看到那段历史,而不是把它抹掉。仅供模型使用的替换副本——被裁剪的工具结果、重新生成的 assistant 消息——不渲染任何内容。 +TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在 transcript/状态区域与编辑器之间内联展示 `ctx.userInteraction` 问题。问题面板会显示进度、编号选项、换行标签和另行缩进的描述;它同时遵守 `maxQuestionOptions` 和 `questionDialogMaxHeight`,用 `↑ N more`/`↓ N more` 标记隐藏选项,并在保持编辑器可见的同时,通过 Page Up 和 Page Down 先分页浏览过长的问题/详情内容,再分页浏览单个超大的选中块。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 ``。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型,以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换从不重写已渲染的 transcript:被它遮蔽的对话仍可阅读,而已落地的压缩(compaction)检查点会在其日志位置添加一行暗色 `… earlier context was compacted …` 标记,因此终端报告的是模型从何处起不再看到那段历史,而不是把它抹掉。仅供模型使用的替换副本——被裁剪的工具结果、重新生成的 assistant 消息——不渲染任何内容。 如果逻辑工作区标签与会话宿主目录不同,嵌入方可以提供 `TuiRuntime.formatCwd`。该覆盖只改变 footer 标签;工具仍使用会话 `cwd`。 @@ -51,11 +51,11 @@ Footer 将会话报告的用量汇总为 `↑`;任 | `showReasoning` | `true` | 渲染 reasoning 块 | | `maxToolOutputLines` | `6` | 折叠工具卡片的头尾预览所保留的输出行数 | | `maxDiffEditLength` | `1000` | 回退到整侧展示前,精确 diff 最多探索的新增与删除行总数 | -| `maxQuestionOptions` | `8` | 问题面板中可见的选项数 | +| `maxQuestionOptions` | `8` | 一次最多可见的选项块数;行数边界可能进一步减少可见数量 | | `maxModelOptions` | `8` | 模型选择器中可见的模型数 | | `maxResumeOptions` | `8` | 恢复选择器中可见的会话数 | | `questionDialogWidth` | `200` | 问题面板宽度(列数),以终端宽度为上限 | -| `questionDialogMaxHeight` | `20` | 问题面板最大行数 | +| `questionDialogMaxHeight` | `20` | 问题面板最大行数,会进一步受限以保留编辑器 | | `modelDialogWidth` | `76` | 模型选择器宽度(列数) | | `modelDialogMaxHeight` | `20` | 模型选择器最大行数 | | `detailsDialogWidth` | `72` | transcript 细节选择器宽度(列数) | diff --git a/packages/ui/tui/src/chat/questions.ts b/packages/ui/tui/src/chat/questions.ts index e5f2c8b806..5d96282860 100644 --- a/packages/ui/tui/src/chat/questions.ts +++ b/packages/ui/tui/src/chat/questions.ts @@ -29,7 +29,10 @@ interface PendingQuestion { } /** Collaborators the question queue needs from the chat channel. */ -export type QuestionQueueDeps = ChatChannelDeps +export interface QuestionQueueDeps extends ChatChannelDeps { + /** Current row budget after reserving the editor. */ + questionMaxHeight(): number +} /** Ask-user-question controller for one chat channel. */ export interface QuestionQueue { @@ -85,6 +88,7 @@ export function createQuestionQueue(deps: QuestionQueueDeps): QuestionQueue { pending.request.questions.length, pending.request.questions.length - pending.answers.length, resolved.maxQuestionOptions, + () => deps.questionMaxHeight(), palette, (selection) => { pending.overlay = undefined @@ -102,10 +106,8 @@ export function createQuestionQueue(deps: QuestionQueueDeps): QuestionQueue { options: { width: resolved.questionDialogWidth, maxHeight: resolved.questionDialogMaxHeight, - anchor: 'bottom-left', - margin: { bottom: 1 }, }, - }) + }, 'inline') pending.overlay = session void session.closed.then((result) => { if (pending.overlay !== session) return diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts index 946055f1d8..a92fdae87a 100644 --- a/packages/ui/tui/src/components/dialogs.ts +++ b/packages/ui/tui/src/components/dialogs.ts @@ -822,10 +822,18 @@ export class ResumePicker implements Component, Focusable { } } -/** Bottom-anchored dialog for one user question with option or custom-answer modes. */ +interface SelectedBlockPage { + offset: number + size: number + maxOffset: number +} + +/** Inline dialog for one user question with option or custom-answer modes. */ export class QuestionDialog implements Component, Focusable { private selectedIndex = 0 private selected = new Set() + private headerPage: SelectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } + private selectedBlockPage: SelectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } private mode: 'options' | 'custom' private error = '' private readonly input = new Input() @@ -838,6 +846,7 @@ export class QuestionDialog implements Component, Focusable { private readonly total: number, private readonly unanswered: number, private readonly maxVisible: number, + private readonly maxHeight: () => number, private readonly palette: Palette, private readonly done: (selection: QuestionSelection) => void, private readonly cancel: () => void, @@ -861,6 +870,14 @@ export class QuestionDialog implements Component, Focusable { handleInput(data: string): void { this.invalidate() + if (matchesKey(data, Key.pageUp)) { + this.pageBackward() + return + } + if (matchesKey(data, Key.pageDown)) { + this.pageForward() + return + } if (this.mode === 'custom') { this.input.focused = this.focused this.input.handleInput(data) @@ -868,8 +885,10 @@ export class QuestionDialog implements Component, Focusable { } const options = this.options if (matchesKey(data, Key.up)) { + this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } this.selectedIndex = this.selectedIndex === 0 ? options.length - 1 : this.selectedIndex - 1 } else if (matchesKey(data, Key.down)) { + this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } this.selectedIndex = this.selectedIndex === options.length - 1 ? 0 : this.selectedIndex + 1 } else if (matchesKey(data, Key.space) && this.question.multiSelect) { if (this.selected.has(this.selectedIndex)) this.selected.delete(this.selectedIndex) @@ -883,6 +902,7 @@ export class QuestionDialog implements Component, Focusable { this.done({ selected: indices.map(index => options[index]?.label).filter((label): label is string => label !== undefined) }) } else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') { this.mode = 'custom' + this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } this.error = '' } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) { this.cancel() @@ -898,75 +918,346 @@ export class QuestionDialog implements Component, Focusable { this.done({ selected: [], custom }) } + /** Page backward through an oversized option, then through question detail. */ + private pageBackward(): void { + if (this.mode === 'options' && this.selectedBlockPage.offset > 0) { + this.selectedBlockPage = { + ...this.selectedBlockPage, + offset: Math.max(0, this.selectedBlockPage.offset - this.selectedBlockPage.size), + } + return + } + this.headerPage = { + ...this.headerPage, + offset: Math.max(0, this.headerPage.offset - this.headerPage.size), + } + } + + /** Page forward through question detail, then through an oversized option. */ + private pageForward(): void { + if (this.headerPage.offset < this.headerPage.maxOffset) { + this.headerPage = { + ...this.headerPage, + offset: Math.min( + this.headerPage.maxOffset, + this.headerPage.offset + this.headerPage.size, + ), + } + return + } + if (this.mode === 'custom') return + this.selectedBlockPage = { + ...this.selectedBlockPage, + offset: Math.min( + this.selectedBlockPage.maxOffset, + this.selectedBlockPage.offset + this.selectedBlockPage.size, + ), + } + } + render(width: number): string[] { this.input.focused = this.focused - const innerWidth = Math.max(1, width - 4) + const horizontalPadding = Math.min(2, Math.max(0, Math.floor((width - 1) / 2))) + const innerWidth = Math.max(1, width - horizontalPadding * 2) const header = `Question ${this.position}/${this.total} (${this.unanswered} unanswered)${this.question.header === undefined ? '' : ` · ${displayText(this.question.header)}`}` - const lines = [ - this.palette.dim(header), - ...wrapTextWithAnsi(this.palette.text(displayText(this.question.question)), innerWidth), + const questionLines = wrapTextWithAnsi( + this.palette.text(displayText(this.question.question)), + innerWidth, + ) + const contentLines = [...questionLines] + const headerLines: string[] = [ + ...wrapTextWithAnsi(this.palette.dim(header), innerWidth), + ...questionLines, ] - const push = (line: string): void => { lines.push(line) } // Supporting detail (e.g. the full plan under review) renders between the // question and the answer surface, kept out of option labels. if (this.question.detail !== undefined) { - push('') - for (const line of wrapTextWithAnsi(displayText(this.question.detail), innerWidth)) push(line) - } - push('') - if (this.mode === 'custom') { - for (const line of this.input.render(innerWidth)) push(line) - push(this.palette.dim(this.options.length > 0 ? 'Enter submit • Esc options' : 'Enter submit • Esc cancel')) - } else { - const options = this.options - const start = Math.max(0, Math.min( - this.selectedIndex - Math.floor(this.maxVisible / 2), - options.length - this.maxVisible, - )) - const end = Math.min(options.length, start + this.maxVisible) - const optionRows = options.slice(start, end).map((option, offset) => { - const index = start + offset - const mark = this.question.multiSelect - ? this.selected.has(index) ? '[x] ' : '[ ] ' - : '' - return `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}` - }) - const descriptionColumn = Math.min( - Math.max(...optionRows.map(row => visibleWidth(row))) + 2, - Math.max(1, Math.floor(innerWidth * 0.55)), - ) - for (let index = start; index < end; index += 1) { - // `index < end <= options.length`; the options array is borrowed immutably for this dialog. - const option = options[index] as NonNullable[number] - const mark = this.question.multiSelect - ? this.selected.has(index) ? '[x] ' : '[ ] ' - : '' - const left = `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}` - const leftStyled = index === this.selectedIndex - ? this.palette.bold(this.palette.accent(left)) - : left - const description = option.description === undefined - ? '' - : `${' '.repeat(Math.max(1, descriptionColumn - visibleWidth(left)))}${this.palette.dim(displayText(option.description))}` - push(`${leftStyled}${description}`) + headerLines.push('') + contentLines.push('') + for (const line of wrapTextWithAnsi(displayText(this.question.detail), innerWidth)) { + headerLines.push(line) + contentLines.push(line) } - if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`)) + } + headerLines.push('') + + const customHint = this.palette.dim(this.options.length > 0 + ? 'Enter submit • Esc options' + : 'Enter submit • Esc cancel') + const footerLines: string[] = [] + if (this.mode === 'custom') { + for (const line of this.input.render(innerWidth)) footerLines.push(line) + for (const line of wrapTextWithAnsi(customHint, innerWidth)) footerLines.push(line) + } else { const controls = [ 'Tab custom answer', - ...(options.length > 1 ? ['↑/↓ navigate'] : []), + ...(this.options.length > 1 ? ['↑/↓ navigate'] : []), ...(this.question.multiSelect ? ['Space toggle'] : []), 'Enter submit', 'Esc interrupt', ] const hint = this.palette.dim(controls.join(' • ')) - for (const line of wrapTextWithAnsi(hint, innerWidth)) push(line) + for (const line of wrapTextWithAnsi(hint, innerWidth)) footerLines.push(line) } if (this.error) { - for (const line of wrapTextWithAnsi(this.palette.error(this.error), innerWidth)) push(line) + for (const line of wrapTextWithAnsi(this.palette.error(this.error), innerWidth)) footerLines.push(line) } - return ['', ...lines, ''].map((line) => { - const clipped = truncateToWidth(line, innerWidth, '') - return ` ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ` + const positionLines = this.mode === 'options' && this.options.length > this.maxVisible + ? [this.palette.dim(`${this.selectedIndex + 1}/${this.options.length}`)] + : [] + + // Options receive only the rows left after fixed chrome and outer padding. + // The final height window handles fixed chrome that cannot fit even alone. + const paddingRows = 2 + const maxHeight = this.maxHeight() + const availableForOptions = Math.max( + this.mode === 'options' ? 4 : 1, + maxHeight - paddingRows - headerLines.length - positionLines.length - footerLines.length, + ) + + const body: string[] = [...headerLines] + const optionLines: string[] = [] + if (this.mode === 'custom') { + for (const line of footerLines) body.push(line) + } else { + const optionBlocks = this.options.map((option, index) => this.renderOptionBlock(option, index, innerWidth)) + const { visibleBlocks, hiddenBefore, hiddenAfter } = this.windowBlocks(optionBlocks, availableForOptions, innerWidth) + if (hiddenBefore > 0) optionLines.push(this.palette.dim(`↑ ${hiddenBefore} more`)) + for (const block of visibleBlocks) { + for (const line of block) optionLines.push(line) + } + if (hiddenAfter > 0) optionLines.push(this.palette.dim(`↓ ${hiddenAfter} more`)) + for (const line of optionLines) body.push(line) + for (const line of positionLines) body.push(line) + for (const line of footerLines) body.push(line) + } + + const rows = ['', ...body, ''] + let visibleRows = rows + if (rows.length <= maxHeight) this.headerPage = { offset: 0, size: 1, maxOffset: 0 } + if (rows.length > maxHeight && this.mode === 'options' && maxHeight >= 6) { + const headerBudget = Math.max( + 0, + maxHeight - optionLines.length - (this.error === '' ? 1 : 2), + ) + const compactFooter = [ + ...this.error === '' + ? [] + : [truncateToWidth(this.palette.error(`Error: ${this.error}`), innerWidth, '…')], + this.compactOptionControls( + innerWidth, + headerBudget === 1 && contentLines.length > headerBudget, + ), + ] + const compactHeader = this.compactQuestionHeader(contentLines, headerBudget, innerWidth) + visibleRows = [...compactHeader, ...optionLines, ...compactFooter] + } else if (rows.length > maxHeight && this.mode === 'custom' && maxHeight >= 2) { + const compactFooterSource = [ + ...this.input.render(innerWidth), + this.compactCustomControls(innerWidth), + ...this.error === '' + ? [] + : [truncateToWidth(this.palette.error(this.error), innerWidth, '…')], + ] + const footerBudget = Math.max(1, maxHeight - 1) + const compactFooter = compactFooterSource.length <= footerBudget + ? compactFooterSource + : footerBudget === 1 + ? compactFooterSource.slice(0, 1) + : [ + ...compactFooterSource.slice(0, 1), + ...compactFooterSource.slice(-(footerBudget - 1)), + ] + const compactHeader = this.compactQuestionHeader( + contentLines, + Math.max(0, maxHeight - compactFooter.length), + innerWidth, + ) + visibleRows = [...compactHeader, ...compactFooter] + } + if (visibleRows.length > maxHeight) { + visibleRows = maxHeight === 1 + ? [this.palette.dim(`↑ ${visibleRows.length} lines hidden`)] + : [ + this.palette.dim(`↑ ${visibleRows.length - maxHeight + 1} lines hidden`), + ...visibleRows.slice(-(maxHeight - 1)), + ] + } + return visibleRows.map((line) => { + const bounded = truncateToWidth(line, innerWidth, '…') + const pad = ' '.repeat(Math.max(0, innerWidth - visibleWidth(bounded))) + const outerPad = ' '.repeat(horizontalPadding) + return `${outerPad}${bounded}${pad}${outerPad}` }) } + + /** Render one option as wrapped label and indented description lines. */ + private renderOptionBlock( + option: NonNullable[number], + index: number, + innerWidth: number, + ): string[] { + const cursor = index === this.selectedIndex ? '›' : ' ' + const number = `${index + 1}. ` + const mark = this.question.multiSelect + ? this.selected.has(index) ? '[x] ' : '[ ] ' + : '' + const labelPrefixPlain = ` ${cursor} ${number}${mark}` + const labelPrefixWidth = visibleWidth(labelPrefixPlain) + const labelBodyWidth = Math.max(1, innerWidth - labelPrefixWidth) + const labelLines = wrapTextWithAnsi(displayText(option.label), labelBodyWidth) + const continuation = ' '.repeat(labelPrefixWidth) + const lines: string[] = [] + for (const [lineIndex, labelLine] of labelLines.entries()) { + const prefix = lineIndex === 0 ? labelPrefixPlain : continuation + const composed = `${prefix}${labelLine}` + lines.push(index === this.selectedIndex ? this.palette.bold(this.palette.accent(composed)) : composed) + } + if (option.description !== undefined) { + const descIndent = ' '.repeat(labelPrefixWidth) + const descBodyWidth = Math.max(1, innerWidth - labelPrefixWidth) + const descLines = wrapTextWithAnsi(displayText(option.description), descBodyWidth) + for (const descLine of descLines) lines.push(`${descIndent}${this.palette.dim(descLine)}`) + } + return lines + } + + /** Keep the question visible when fixed chrome must be compacted. */ + private compactQuestionHeader( + contentLines: readonly string[], + budget: number, + innerWidth: number, + ): string[] { + if (budget <= 0) return [] + if (contentLines.length <= budget) { + this.headerPage = { offset: 0, size: 1, maxOffset: 0 } + return [...contentLines] + } + const pageSize = Math.max(1, budget - 1) + const maxOffset = Math.max(0, contentLines.length - pageSize) + const offset = Math.min(this.headerPage.offset, maxOffset) + this.headerPage = { offset, size: pageSize, maxOffset } + const keptLines = contentLines.slice(offset, offset + pageSize) + if (budget === 1) { + // A page is non-empty because pageSize is one and offset is clamped inside contentLines. + return [keptLines[0] as string] + } + return [ + ...keptLines, + this.pagerStatus(offset + 1, offset + keptLines.length, contentLines.length, innerWidth), + ] + } + + /** Keep Page Up / Page Down discoverable when a full pager status cannot fit. */ + private pagerStatus(first: number, last: number, total: number, innerWidth: number): string { + const full = `… lines ${first}-${last}/${total} • PgUp/PgDn` + const compact = `PgUp/PgDn ${first}/${total}` + return this.palette.dim(truncateToWidth( + visibleWidth(full) <= innerWidth ? full : compact, + innerWidth, + '…', + )) + } + + /** Render custom-mode controls on one row when the header must compact. */ + private compactCustomControls(innerWidth: number): string { + const controls = this.options.length > 0 + ? 'Enter submit • Esc options' + : 'Enter submit • Esc cancel' + const fallback = this.options.length > 0 ? '↵ Esc options' : 'Enter Esc cancel' + const line = visibleWidth(controls) <= innerWidth ? controls : fallback + return this.palette.dim(truncateToWidth(line, innerWidth, '…')) + } + + /** Render a one-row option footer that retains every mode-specific control. */ + private compactOptionControls(innerWidth: number, showPager = false): string { + const controls = [ + ...(this.options.length > 1 ? ['↑/↓'] : []), + 'Tab custom', + ...(this.question.multiSelect ? ['Space toggle'] : []), + 'Enter', + 'Esc interrupt', + ...(showPager ? ['PgUp/PgDn'] : []), + ].join(' • ') + const optionNavigation = this.options.length > 1 ? '↑↓ ' : '' + const fallback = showPager + ? `P↑↓ ${optionNavigation}Tab${this.question.multiSelect ? ' S' : ''}↵Esc` + : this.question.multiSelect ? `${optionNavigation}Tab Sp ↵Esc` : `${optionNavigation}Tab ↵ Esc` + const line = visibleWidth(controls) <= innerWidth ? controls : fallback + return this.palette.dim(truncateToWidth(line, innerWidth, '…')) + } + + /** + * Choose option blocks that fit while keeping the selected option visible. + * Omitted blocks are counted at each end for explicit overflow markers. + */ + private windowBlocks( + blocks: readonly string[][], + budget: number, + innerWidth: number, + ): { visibleBlocks: string[][]; hiddenBefore: number; hiddenAfter: number } { + const totalLines = blocks.reduce((sum, block) => sum + block.length, 0) + if (totalLines <= budget && blocks.length <= this.maxVisible) { + return { visibleBlocks: [...blocks], hiddenBefore: 0, hiddenAfter: 0 } + } + // `blocks` is dense and selectedIndex is derived from the same options. + let start = this.selectedIndex + let end = this.selectedIndex + 1 + /* v8 ignore next -- selectedIndex stays inside [0, options.length). */ + let used = blocks[this.selectedIndex]?.length ?? 0 + const markerLines = (before: number, after: number): number => + (before > 0 ? 1 : 0) + (after > 0 ? 1 : 0) + const fits = (nextStart: number, nextEnd: number, nextUsed: number): boolean => + nextEnd - nextStart <= this.maxVisible + && nextUsed + markerLines(nextStart, blocks.length - nextEnd) <= budget + const selectedMarkers = markerLines(start, blocks.length - end) + if (used + selectedMarkers > budget) { + /* v8 ignore next -- selectedIndex stays inside [0, options.length). */ + const selectedBlock = blocks[this.selectedIndex] ?? [] + const hiddenBefore = start + const hiddenAfter = blocks.length - end + const pageSize = budget - selectedMarkers - 1 + const maxOffset = Math.max(0, selectedBlock.length - pageSize) + const offset = Math.min(this.selectedBlockPage.offset, maxOffset) + this.selectedBlockPage = { offset, size: pageSize, maxOffset } + const keptLines = selectedBlock.slice(offset, offset + pageSize) + const first = offset + 1 + const last = offset + keptLines.length + const overflow = this.pagerStatus(first, last, selectedBlock.length, innerWidth) + return { + visibleBlocks: [[...keptLines, overflow]], + hiddenBefore, + hiddenAfter, + } + } + this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } + let expanded = true + while (expanded && (start > 0 || end < blocks.length)) { + expanded = false + if (end < blocks.length) { + /* v8 ignore next -- guarded by `end < blocks.length` above. */ + const next = blocks[end]?.length ?? 0 + if (fits(start, end + 1, used + next)) { + used += next + end += 1 + expanded = true + continue + } + } + if (start > 0) { + /* v8 ignore next -- guarded by `start > 0` above. */ + const previous = blocks[start - 1]?.length ?? 0 + if (fits(start - 1, end, used + previous)) { + used += previous + start -= 1 + expanded = true + } + } + } + return { + visibleBlocks: blocks.slice(start, end), + hiddenBefore: start, + hiddenAfter: blocks.length - end, + } + } } diff --git a/packages/ui/tui/src/extension/overlay-manager.ts b/packages/ui/tui/src/extension/overlay-manager.ts index 59d84ff3d5..ab80f49cab 100644 --- a/packages/ui/tui/src/extension/overlay-manager.ts +++ b/packages/ui/tui/src/extension/overlay-manager.ts @@ -12,7 +12,6 @@ import type { TuiExtensionService } from '../index.ts' import type { Component, Focusable, - OverlayHandle, } from '@earendil-works/pi-tui' import type { TuiComponent, @@ -36,14 +35,20 @@ export interface TuiOverlayDriver { theme(): TuiTheme /** Escape text at the terminal display boundary. */ display(value: string): string - /** Mount one guarded component and return its private pi-tui handle. */ - show(component: Component, options: TuiOverlayOptions | undefined): OverlayHandle + /** Mount one guarded modal and return its private focus/lifecycle handle. */ + show(component: Component, options: TuiOverlayOptions | undefined, placement: TuiOverlayPlacement): TuiModalHandle /** Invalidate the mounted UI and request a render. */ invalidate(): void /** Report a contained extension failure. */ reportError(error: unknown): void } +type TuiOverlayPlacement = 'overlay' | 'inline' + +interface TuiModalHandle { + hide(): void +} + interface OverlayEntry { readonly request: TuiOverlayRequest readonly controller: AbortController @@ -51,9 +56,10 @@ interface OverlayEntry { readonly closed: Promise readonly resolveClosed: (outcome: TuiOverlayOutcome) => void readonly session: TuiOverlaySession + readonly placement: TuiOverlayPlacement state: TuiOverlayState component?: GuardedOverlayComponent - handle?: OverlayHandle + handle?: TuiModalHandle removeRequestAbort?: () => void outcome?: TuiOverlayOutcome failing?: boolean @@ -165,11 +171,12 @@ export class TuiOverlayManager { } /** - * Queue one overlay without assigning Cordis ownership. + * Queue one modal without assigning Cordis ownership. * @param request - component factory, constraints, and request signal. + * @param placement - terminal overlay for extensions, or inline for the built-in question panel. * @returns an internal session that can close with an ownership reason. */ - open(request: TuiOverlayRequest): TuiOverlaySession & { + open(request: TuiOverlayRequest, placement: TuiOverlayPlacement = 'overlay'): TuiOverlaySession & { closeWith(reason: Exclude): Promise } { if (!this.accepting) throw new Error('TUI is shutting down') @@ -202,6 +209,7 @@ export class TuiOverlayManager { closed: deferred.promise, resolveClosed: deferred.resolve, session, + placement, state: 'queued', } if (requestSignal?.aborted === true) { @@ -251,7 +259,7 @@ export class TuiOverlayManager { }) entry.component = guarded try { - const handle = this.driver.show(guarded, entry.request.options) + const handle = this.driver.show(guarded, entry.request.options, entry.placement) if (this.active !== entry) { this.hide(handle) return @@ -306,7 +314,7 @@ export class TuiOverlayManager { } } - private hide(handle: OverlayHandle): void { + private hide(handle: TuiModalHandle): void { try { handle.hide() } catch (error) { diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index f50894b59d..0ddb73ed9c 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -15,6 +15,7 @@ import { ProcessTerminal, matchesKey, visibleWidth, + type Component, type EditorTheme, type SlashCommand, type TerminalColorScheme, @@ -289,6 +290,23 @@ interface FadingStatus { timer: ReturnType } +/** Width/height adapter for a modal component rendered inside the base TUI flow. */ +class InlineModalComponent extends Container { + constructor( + component: Component, + private readonly width: number, + private readonly maxHeight: number, + ) { + super() + this.addChild(component) + } + + override render(width: number): string[] { + const lines = super.render(Math.max(1, Math.min(width, this.width))) + return lines.slice(0, Math.max(1, this.maxHeight)) + } +} + /** Lifecycle handle for a mounted interactive terminal channel. */ export interface TuiController { /** Stop rendering, restore the terminal, and reject pending questions. */ @@ -316,6 +334,7 @@ export function createTuiChat( const ui = new TUI(runtime.terminal, resolved.showHardwareCursor) const chat = new Container() const todoContainer = new Container() + const questionContainer = new Container() const inputTemplate = parseTuiPromptTemplate(displayInlineText(resolved.theme.inputPrompt)) const renderInputPrompt = (): string => renderTuiPromptTemplate(inputTemplate, valueName => ctx.tuiPrompt.get(valueName)) const initialInputPrompt = renderInputPrompt() @@ -480,6 +499,7 @@ export function createTuiChat( ui.addChild(todoContainer) ui.addChild(compactionStatusLine) ui.addChild(promptContext) + ui.addChild(questionContainer) ui.addChild(editor) ui.setFocus(editor) const updateTerminalTitle = (): void => { @@ -529,14 +549,32 @@ export function createTuiChat( }), theme: () => extensionTheme, display: displayText, - show: (component, options) => ui.showOverlay(component, options === undefined - ? undefined - : { - ...options, - ...typeof options.margin === 'object' - ? { margin: { ...options.margin } } - : {}, - }), + show: (component, options, placement) => { + if (placement === 'overlay') { + return ui.showOverlay(component, options === undefined + ? undefined + : { + ...options, + ...typeof options.margin === 'object' + ? { margin: { ...options.margin } } + : {}, + }) + } + const modal = new InlineModalComponent( + component, + resolved.questionDialogWidth, + resolved.questionDialogMaxHeight, + ) + questionContainer.clear() + questionContainer.addChild(modal) + ui.setFocus(component) + return { + hide(): void { + questionContainer.clear() + ui.setFocus(editor) + }, + } + }, invalidate: requestRender, reportError: (error) => { const message = errorChain(error) @@ -956,6 +994,14 @@ export function createTuiChat( overlayManager, requestRender, isDisposed, + questionMaxHeight: () => { + const width = runtime.terminal.columns + const editorRows = editor.render(width).length + return Math.max(1, Math.min( + resolved.questionDialogMaxHeight, + runtime.terminal.rows - editorRows, + )) + }, }) const resume = createResumeController({ diff --git a/packages/ui/tui/tests/snapshots/question-dialog-detail-paged.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog-detail-paged.expected.txt new file mode 100644 index 0000000000..415bedd976 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/question-dialog-detail-paged.expected.txt @@ -0,0 +1,39 @@ +terminal 56x20 buffer=normal length=25 base=5 viewport=5 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=56 viewportRow=13 bufferRow=18 +viewport +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 " + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-55 dim +8| " Review the complete plan including every required " +9| " checkpoint including every required checkpoint " +10| " including every required checkpoint including every " +11| " required checkpoint including every required " +12| " checkpoint including every required checkpoint " +13| " including every required checkpoint including every " +14| " required checkpoint including every required " +15| " checkpoint including every required checkpoint " +16| " including every required checkpoint including every " +17| " required checkpoint visible plan tail " +18| " … lines 4-13/13 • PgUp/PgDn " + style 2-28 dim +19| " › 1. [ ] Code Mode " + style 2-20 fg=bright-magenta bold +20| " run_code programs and captured output with " + style 12-53 dim +21| " … lines 1-2/12 • PgUp/PgDn " + style 2-27 dim +22| " ↓ 3 more " + style 2-9 dim +23| " ↑↓ Tab Sp ↵Esc " + style 2-15 dim +24| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/question-dialog-paged.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog-paged.expected.txt new file mode 100644 index 0000000000..2969918f17 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/question-dialog-paged.expected.txt @@ -0,0 +1,39 @@ +terminal 56x20 buffer=normal length=25 base=5 viewport=5 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=56 viewportRow=16 bufferRow=21 +viewport +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 " + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-55 dim +8| " Review the complete plan including every required " +9| " checkpoint including every required checkpoint " +10| " including every required checkpoint including every " +11| " required checkpoint including every required " +12| " checkpoint including every required checkpoint " +13| " including every required checkpoint including every " +14| " required checkpoint including every required " +15| " checkpoint including every required checkpoint " +16| " including every required checkpoint including every " +17| " required checkpoint visible plan tail " +18| " … lines 4-13/13 • PgUp/PgDn " + style 2-28 dim +19| " detail with complete wrapped detail " + style 12-46 dim +20| " visible tail " + style 12-23 dim +21| " … lines 11-12/12 • PgUp/PgDn " + style 2-29 dim +22| " ↓ 3 more " + style 2-9 dim +23| " ↑↓ Tab Sp ↵Esc " + style 2-15 dim +24| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/question-dialog-single-option.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog-single-option.expected.txt index a81556d42a..6cfef15ead 100644 --- a/packages/ui/tui/tests/snapshots/question-dialog-single-option.expected.txt +++ b/packages/ui/tui/tests/snapshots/question-dialog-single-option.expected.txt @@ -1,7 +1,7 @@ terminal 56x20 buffer=normal length=20 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=0 viewportRow=19 bufferRow=19 +cursor hidden column=56 viewportRow=16 bufferRow=16 viewport 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -21,20 +21,20 @@ viewport style 18-31 dim style 34-50 dim style 53-55 dim -8| " dsh > " +8| " " +9| " Question 1/1 (1 unanswered) · Confirm " + style 2-38 dim +10| " Continue with this change? " +11| " " +12| " › 1. Proceed " + style 2-14 fg=bright-magenta bold +13| " Apply the proposed change " + style 8-32 dim +14| " Tab custom answer • Enter submit • Esc interrupt " + style 2-49 dim +15| " " +16| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse -9-11| -12| " " -13| " Question 1/1 (1 unanswered) · Confirm " - style 2-38 dim -14| " Continue with this change? " -15| " " -16| " › 1. Proceed Apply the proposed change " - style 2-13 fg=bright-magenta bold - style 16-40 dim -17| " Tab custom answer • Enter submit • Esc interrupt " - style 2-49 dim -18| " " -19| +17-19| diff --git a/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt index 14193efdda..ecd949cb8d 100644 --- a/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt +++ b/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt @@ -1,40 +1,40 @@ -terminal 56x20 buffer=normal length=20 base=0 viewport=0 +terminal 56x20 buffer=normal length=25 base=5 viewport=5 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=56 viewportRow=17 bufferRow=17 +cursor hidden column=56 viewportRow=17 bufferRow=22 viewport -0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -1| " Snapshot agent ready." - style 1-21 dim -2| " main-session" - style 1-12 dim -3| -4| "Assistant " - style 0-8 fg=bright-magenta bold underline -5| " " -6| " Question 1/3 (3 unanswered) · Coverage " - style 2-39 dim -7| " Which advanced TUI states belong in the required " -8| " matrix? " -9| " " -10| " › 1. [ ] Code Mode run_code programs and capture " - style 2-19 fg=bright-magenta bold - style 25-53 dim -11| " 2. [ ] Workflows phases and parallel agents " - style 25-50 dim -12| " 3. [ ] Cordis tools inspect, mount, and unmount " - style 25-51 dim -13| " 1/4 " - style 2-4 dim -14| " Tab custom answer • ↑/↓ navigate • Space toggle • " - style 2-55 dim -15| " Enter submit • Esc interrupt " +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 " + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-55 dim +8| " Review the complete plan including every required " +9| " checkpoint including every required checkpoint " +10| " including every required checkpoint including every " +11| " required checkpoint including every required " +12| " checkpoint including every required checkpoint " +13| " including every required checkpoint including every " +14| " required checkpoint including every required " +15| " checkpoint including every required checkpoint " +16| " including every required checkpoint including every " +17| " … lines 4-12/13 • PgUp/PgDn " + style 2-28 dim +18| " detail with complete wrapped detail " + style 12-46 dim +19| " visible tail " + style 12-23 dim +20| " … lines 11-12/12 • PgUp/PgDn " style 2-29 dim -16| " Select at least one option, or press Tab for a " - style 2-55 fg=red -17| " custom answer. " - style 2-15 fg=red -18| " " -19| +21| " ↓ 3 more " + style 2-9 dim +22| " Error: Select at least one option, or press Tab for… " + style 2-52 fg=red +23| " ↑↓ Tab Sp ↵Esc " + style 2-15 dim +24| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/question-dialog.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog.expected.txt index 640bd6fed2..4b576099ae 100644 --- a/packages/ui/tui/tests/snapshots/question-dialog.expected.txt +++ b/packages/ui/tui/tests/snapshots/question-dialog.expected.txt @@ -1,39 +1,39 @@ -terminal 56x20 buffer=normal length=20 base=0 viewport=0 +terminal 56x20 buffer=normal length=25 base=5 viewport=5 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=0 viewportRow=19 bufferRow=19 +cursor hidden column=56 viewportRow=19 bufferRow=24 viewport -0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -1| " Snapshot agent ready." - style 1-21 dim -2| " main-session" - style 1-12 dim -3| -4| "Assistant " - style 0-8 fg=bright-magenta bold underline 5| "Model wait 0.0s " style 0-14 dim 6| -7| " " -8| " Question 1/3 (3 unanswered) · Coverage " - style 2-39 dim -9| " Which advanced TUI states belong in the required " -10| " matrix? " -11| " " -12| " › 1. [ ] Code Mode run_code programs and capture " - style 2-19 fg=bright-magenta bold - style 25-53 dim -13| " 2. [ ] Workflows phases and parallel agents " - style 25-50 dim -14| " 3. [ ] Cordis tools inspect, mount, and unmount " - style 25-51 dim -15| " 1/4 " - style 2-4 dim -16| " Tab custom answer • ↑/↓ navigate • Space toggle • " - style 2-55 dim -17| " Enter submit • Esc interrupt " - style 2-29 dim -18| " " -19| +7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 " + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-55 dim +8| " Which advanced TUI states belong in the required " +9| " matrix? " +10| " " +11| " Review the complete plan including every required " +12| " checkpoint including every required checkpoint " +13| " including every required checkpoint including every " +14| " required checkpoint including every required " +15| " checkpoint including every required checkpoint " +16| " including every required checkpoint including every " +17| " required checkpoint including every required " +18| " … lines 1-10/13 • PgUp/PgDn " + style 2-28 dim +19| " › 1. [ ] Code Mode " + style 2-20 fg=bright-magenta bold +20| " run_code programs and captured output with " + style 12-53 dim +21| " … lines 1-2/12 • PgUp/PgDn " + style 2-27 dim +22| " ↓ 3 more " + style 2-9 dim +23| " ↑↓ Tab Sp ↵Esc " + style 2-15 dim +24| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt b/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt index e5a625afc0..5cb2529355 100644 --- a/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt +++ b/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt @@ -1,7 +1,7 @@ -terminal 100x34 buffer=normal length=34 base=0 viewport=0 +terminal 100x34 buffer=normal length=40 base=6 viewport=6 lifecycle started=1 stopped=0 progress=inactive title "Unsafe terminal title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" -cursor hidden column=0 viewportRow=33 bufferRow=33 +cursor hidden column=100 viewportRow=33 bufferRow=39 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -48,15 +48,30 @@ buffer 24| 25| "Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-62 fg=red -26| " " -27| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +26-27| +28| "Plan" + style 0-3 fg=bright-magenta bold +29| " ● Unsafe todo \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" + style 2-2 fg=yellow +30| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-57 dim + style 60-69 dim +31| " " +32| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 2-90 dim -28| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " -29| " " -30| " › 1. Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m Unsafe detail \\x1b]2;snapshot-c " - style 2-65 fg=bright-magenta bold - style 67-97 dim -31| " Tab custom answer • Enter submit • Esc interrupt " +33| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +34| " " +35| " › 1. Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 2-66 fg=bright-magenta bold +36| " Unsafe detail \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 8-66 dim +37| " Tab custom answer • Enter submit • Esc interrupt " style 2-49 dim -32| " " -33| +38| " " +39| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 72718cfae1..21696153a2 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -49,6 +49,8 @@ const CHECKPOINTS = [ 'details-selector', 'untrusted-controls', 'question-dialog', + 'question-dialog-detail-paged', + 'question-dialog-paged', 'question-dialog-single-option', 'question-dialog-validation', 'surface-before-compaction', @@ -786,9 +788,13 @@ describe('TUI terminal-state snapshots', () => { id: 'coverage', header: 'Coverage', question: 'Which advanced TUI states belong in the required matrix?', + detail: `Review the complete plan ${'including every required checkpoint '.repeat(12)}visible plan tail`, multiSelect: true, options: [ - { label: 'Code Mode', description: 'run_code programs and captured output' }, + { + label: 'Code Mode', + description: `run_code programs and captured output ${'with complete wrapped detail '.repeat(12)}visible tail`, + }, { label: 'Workflows', description: 'phases and parallel agents' }, { label: 'Cordis tools', description: 'inspect, mount, and unmount' }, { label: 'Compaction', description: 'surface replacement and reflow' }, @@ -803,6 +809,14 @@ describe('TUI terminal-state snapshots', () => { await harness.terminal.waitForFrame(beforeQuestion) await checkpoint('question-dialog', harness.terminal) + await renderAfter(harness, () => { harness.terminal.send('\x1b[6~') }) + await checkpoint('question-dialog-detail-paged', harness.terminal) + + await renderAfter(harness, () => { + for (let page = 0; page < 30; page += 1) harness.terminal.send('\x1b[6~') + }) + await checkpoint('question-dialog-paged', harness.terminal) + await renderAfter(harness, () => { harness.terminal.send('\r') }) await checkpoint('question-dialog-validation', harness.terminal) controller.abort() diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index b442df0710..c5c12ffc5d 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -50,6 +50,7 @@ import { disposeTuiTestHarness, type TuiHarnessOptions, } from './harness.ts' +import { HeadlessTerminal } from './headless-terminal.ts' import { TestSessionQueryService } from './session-query.ts' const UNUSED_TOOL_OUTPUT: ToolDefinition['output'] = { @@ -5510,6 +5511,54 @@ describe('tool cards and surface replay', () => { }) describe('TUI user-interaction dialogs', () => { + it('limits the visible option window to maxQuestionOptions', async () => { + const result = await setup({ + config: { maxQuestionOptions: 1, questionDialogWidth: 60, questionDialogMaxHeight: 20 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'cap', + question: 'Pick one', + options: [{ label: 'Visible first' }, { label: 'Hidden second' }], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('Visible first') + expect(result.terminal.output).not.toContain('Hidden second') + expect(result.terminal.output).toContain('↓ 1 more') + result.terminal.send('\x03') + await rejected + + await dispose(result) + }) + + it('renders a pending question between the transcript and editor', async () => { + const result = await setup({ + config: { questionDialogWidth: 40, questionDialogMaxHeight: 10 }, + }) + result.terminal.send('draft input') + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'placement', + question: 'Pick one', + options: [{ label: 'First' }, { label: 'Second' }], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.resize(60, 20) + await tick() + const render = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + const questionIndex = render.indexOf('Pick one') + const editorIndex = render.indexOf('draft input') + expect(questionIndex).toBeGreaterThanOrEqual(0) + expect(editorIndex).toBeGreaterThan(questionIndex) + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + it('answers single-select, multi-select, custom, and optionless questions', async () => { const result = await setup({ config: { maxQuestionOptions: 1 } }) @@ -5603,6 +5652,468 @@ describe('TUI user-interaction dialogs', () => { await dispose(result) }) + it('scrolls tall option lists with ↑/↓ overflow markers when the dialog height is capped', async () => { + const result = await setup({ + config: { + questionDialogWidth: 60, + questionDialogMaxHeight: 12, + maxQuestionOptions: 8, + }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'scroll', + question: 'Pick one', + options: [ + { label: 'Alpha', description: 'first choice with a description that will wrap to multiple lines when the dialog is narrow' }, + { label: 'Bravo', description: 'second choice' }, + { label: 'Charlie', description: 'third choice' }, + { label: 'Delta', description: 'fourth choice' }, + { label: 'Echo', description: 'fifth choice' }, + { label: 'Foxtrot', description: 'sixth choice' }, + ], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('↓') + expect(result.terminal.output).toContain('more') + for (let step = 0; step < 5; step += 1) result.terminal.send('\x1b[B') + await tick() + expect(result.terminal.output).toContain('↑') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('keeps controls visible when the selected option block exceeds the row budget', async () => { + const result = await setup({ + config: { questionDialogWidth: 40, questionDialogMaxHeight: 10 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'oversize', + question: 'Pick one', + options: [ + { label: 'Huge', description: `start ${'middle '.repeat(40)}visible tail` }, + { label: 'Other' }, + ], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('Huge') + expect(result.terminal.output).toContain('PgUp/PgDn') + expect(result.terminal.output).toContain('↑↓ Tab ↵ Esc') + expect(result.terminal.output).not.toContain('visible tail') + for (let page = 0; page < 30; page += 1) result.terminal.send('\x1b[6~') + await tick() + expect(result.terminal.output).toContain('visible tail') + for (let page = 0; page < 30; page += 1) result.terminal.send('\x1b[5~') + result.terminal.send('\x1b[6~') + await tick() + expect(result.terminal.output).toContain('start middle') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('pages long question detail so every plan-review line remains reachable', async () => { + const result = await setup({ + config: { questionDialogWidth: 20, questionDialogMaxHeight: 10 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'long-detail', + question: 'Approve this plan?', + detail: `visible start ${'review step '.repeat(60)}visible tail`, + options: [{ label: 'Approve' }, { label: 'Reject' }], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.resize(60, 20) + await tick() + const initialRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(initialRender).toContain('plan?') + expect(initialRender).toContain('visible start') + expect(initialRender).not.toContain('visible tail') + expect(initialRender).toMatch(/PgUp\/PgDn \d+\/\d+/u) + for (let page = 0; page < 30; page += 1) result.terminal.send('\x1b[6~') + await tick() + const finalRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(finalRender).toContain('visible tail') + expect(finalRender).toContain('Approve') + result.terminal.send('\x1b[B') + await tick() + const movedRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(movedRender).toContain('visible tail') + expect(movedRender).toContain('Reject') + result.terminal.send('\x1b[A') + result.terminal.send('\t') + await tick() + result.terminal.send('\x1b[6~') + await tick() + result.terminal.send('\x1b[5~') + result.terminal.resize(61, 20) + await tick() + const customPagedRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(customPagedRender).not.toContain('visible tail') + expect(customPagedRender).toContain('Esc options') + result.terminal.send('\x1b') + for (let page = 0; page < 30; page += 1) result.terminal.send('\x1b[5~') + await tick() + const restoredRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(restoredRender).toContain('visible start') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('reclaims enough rows to keep selected content, paging, and option markers visible', async () => { + const result = await setup({ + config: { questionDialogWidth: 60, questionDialogMaxHeight: 8 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'one-row', + question: 'Pick one', + options: [ + { label: 'Selected first', description: `start ${'middle '.repeat(30)}visible tail` }, + { label: 'Hidden second' }, + ], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('Selected first') + expect(result.terminal.output).not.toContain('Hidden second') + expect(result.terminal.output).toContain('↓ 1 more') + expect(result.terminal.output).toContain('PgUp/PgDn') + expect(result.terminal.output).toContain('Esc interrupt') + for (let page = 0; page < 30; page += 1) result.terminal.send('\x1b[6~') + await tick() + expect(result.terminal.output).toContain('visible tail') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('preserves both option markers and controls at the minimum configured height', async () => { + const result = await setup({ + config: { questionDialogWidth: 60, questionDialogMaxHeight: 6 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'minimum-options', + question: 'Pick one', + multiSelect: true, + options: ['One', 'Two', 'Three', 'Four', 'Five'].map(label => ({ + label, + description: `${label} ${'wrapped detail '.repeat(20)}`, + })), + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.send('\x1b[B') + result.terminal.send('\x1b[B') + await tick() + expect(result.terminal.output).toContain('↑ 2 more') + expect(result.terminal.output).toContain('Three') + expect(result.terminal.output).toContain('PgUp/PgDn') + expect(result.terminal.output).toContain('↓ 2 more') + expect(result.terminal.output).toContain('Tab custom') + expect(result.terminal.output).toContain('Space toggle') + expect(result.terminal.output).toContain('Esc interrupt') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Error: Select at least one') + result.terminal.resize(61) + await tick() + const validationRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(validationRender).toContain('Tab custom') + expect(validationRender).toContain('Space toggle') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('preserves detail text and every action when one compact header row remains', async () => { + const result = await setup({ + config: { questionDialogWidth: 20, questionDialogMaxHeight: 6 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'one-header-row', + question: 'Plan?', + detail: 'abcdvisible tail', + multiSelect: true, + options: [ + { label: 'Yes', description: 'accept' }, + { label: 'No', description: 'reject' }, + ], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.resize(60, 20) + await tick() + const initialRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(initialRender).toContain('P↑↓ ↑↓ Tab S↵Esc') + result.terminal.send('\x1b[6~') + result.terminal.send('\x1b[6~') + await tick() + const detailRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(detailRender).toContain('visible tail') + result.terminal.send('\x03') + await rejected + + const single = result.ctx.userInteraction.ask({ + questions: [{ + id: 'one-header-row-single', + question: 'Plan?', + detail: 'abcdvisible tail', + options: [ + { label: 'Yes', description: 'accept' }, + { label: 'No', description: 'reject' }, + ], + }], + }) + const singleRejected = expect(single).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.resize(61, 20) + await tick() + const singleRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(singleRender).toContain('P↑↓ ↑↓ Tab↵Esc') + result.terminal.send('\x03') + await singleRejected + + const compact = result.ctx.userInteraction.ask({ + questions: [{ + id: 'one-header-row-compact', + question: 'Pick?', + multiSelect: true, + options: [ + { label: 'Yes', description: 'accept' }, + { label: 'No', description: 'reject' }, + ], + }], + }) + const compactRejected = expect(compact).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.resize(60, 20) + await tick() + const compactRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(compactRender).toContain('↑↓ Tab Sp ↵Esc') + result.terminal.send('\x03') + await compactRejected + + const oneOption = result.ctx.userInteraction.ask({ + questions: [{ + id: 'one-header-row-one-option', + question: 'Pick?', + detail: 'Review every line.', + options: [{ label: 'Yes', description: 'wrapped detail '.repeat(8) }], + }], + }) + const oneOptionRejected = expect(oneOption).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.resize(61, 20) + await tick() + const oneOptionRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(oneOptionRender).toContain('P↑↓ Tab↵Esc') + expect(oneOptionRender).not.toContain('P↑↓ ↑↓') + result.terminal.send('\x03') + await oneOptionRejected + await dispose(result) + }) + + it('expands the visible option window forward and backward around the selection', async () => { + const result = await setup({ + config: { + questionDialogWidth: 60, + questionDialogMaxHeight: 14, + maxQuestionOptions: 8, + }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'middle-scroll', + question: 'Pick one', + options: [ + { label: 'One', description: 'a' }, + { label: 'Two', description: 'b' }, + { label: 'Three', description: 'c' }, + { label: 'Four', description: 'd' }, + { label: 'Five', description: 'e' }, + { label: 'Six', description: 'f' }, + { label: 'Seven', description: 'g' }, + { label: 'Eight', description: 'h' }, + { label: 'Nine', description: 'i' }, + { label: 'Ten', description: 'j' }, + ], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + for (let step = 0; step < 4; step += 1) result.terminal.send('\x1b[B') + await tick() + expect(result.terminal.output).toContain('↑') + expect(result.terminal.output).toContain('↓') + expect(result.terminal.output).toContain('Five') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('wraps a long option label across multiple lines instead of truncating it', async () => { + const result = await setup({ config: { questionDialogWidth: 40 } }) + const longLabel = 'this is a very long option label that will not fit on one line in a narrow dialog' + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'long-label', + question: 'Pick one', + options: [{ label: longLabel }, { label: 'Short' }], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('narrow dialog') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('wraps fixed question chrome within the minimum dialog width', async () => { + const result = await setup({ config: { questionDialogWidth: 20 } }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ id: 'narrow', question: 'Answer?' }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).not.toContain('Question 1/1 (1 unanswered)') + expect(result.terminal.output).toContain('unanswered)') + expect(result.terminal.output).not.toContain('Enter submit • Esc cancel') + expect(result.terminal.output).toContain('Esc cancel') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('keeps custom controls visible at the minimum dialog height', async () => { + const result = await setup({ + config: { questionDialogWidth: 20, questionDialogMaxHeight: 6 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ id: 'short-viewport', question: 'Answer this deliberately long question?' }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('long question?') + expect(result.terminal.output).toContain('Esc cancel') + result.terminal.resize(60, 4) + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Enter an answer') + expect(result.terminal.output).toContain('long question?') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('compacts custom controls for a question that also has options', async () => { + const result = await setup({ + config: { questionDialogWidth: 20, questionDialogMaxHeight: 6 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'compact-custom-options', + question: 'Choose or type a deliberately long answer', + options: [{ label: 'Default' }], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.send('\t') + await tick() + expect(result.terminal.output).toContain('Esc options') + result.terminal.send('\x1b') + await tick() + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('reports hidden question rows when the viewport leaves one row', async () => { + const result = await setup({ + config: { questionDialogWidth: 60, questionDialogMaxHeight: 6 }, + }) + result.terminal.resize(60, 2) + const answer = result.ctx.userInteraction.ask({ + questions: [{ id: 'one-row-dialog', question: 'Answer this deliberately long question?' }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('lines hidden') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('keeps question text when the viewport leaves two question rows', async () => { + const result = await setup({ + config: { questionDialogWidth: 60, questionDialogMaxHeight: 6 }, + }) + result.terminal.resize(60, 3) + const answer = result.ctx.userInteraction.ask({ + questions: [{ id: 'two-row-dialog', question: 'Answer this deliberately long question?' }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('long question?') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('bounds option mode when the viewport leaves three question rows', async () => { + const result = await setup({ + config: { questionDialogWidth: 60, questionDialogMaxHeight: 6 }, + }) + result.terminal.resize(60, 4) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'three-row-options', + question: 'Pick one', + options: [{ label: 'First' }, { label: 'Second' }], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('lines hidden') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('keeps question rows within a sub-five-column viewport', async () => { + const terminal = new HeadlessTerminal(4, 12) + const result = await createTuiTestHarness(terminal, vi.fn(), { + config: { questionDialogWidth: 20 }, + }) + const beforeQuestion = terminal.frames + const answer = result.ctx.userInteraction.ask({ + questions: [{ id: 'narrow-viewport', question: 'Pick?', options: [{ label: 'Yes' }] }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await terminal.waitForFrame(beforeQuestion) + await expect(terminal.snapshot()).resolves.toContain('terminal 4x12') + terminal.send('\x03') + await rejected + await disposeTuiTestHarness(result) + }) + it('asks batches in order and rejects cancelled or aborted work', async () => { const result = await setup() const preAborted = new AbortController() From 5baa86b97f7d0c207bc0dfab1ffabf5c3bba407b Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 3 Aug 2026 17:22:51 +0800 Subject: [PATCH 44/52] fix(web): align subagent catalog spacing --- .../branchless-layout.expected.md | 5 ++ apps/web/tests/subagent-conversation.e2e.ts | 75 +++++++++++++++++++ .../client/SubagentCatalogAction.module.css | 70 +++++++++++------ 3 files changed, 127 insertions(+), 23 deletions(-) create mode 100644 apps/web/tests/snapshots/subagent-conversation/branchless-layout.expected.md diff --git a/apps/web/tests/snapshots/subagent-conversation/branchless-layout.expected.md b/apps/web/tests/snapshots/subagent-conversation/branchless-layout.expected.md new file mode 100644 index 0000000000..962e68b808 --- /dev/null +++ b/apps/web/tests/snapshots/subagent-conversation/branchless-layout.expected.md @@ -0,0 +1,5 @@ +menu: 360px; padding 4px; gap 4px; radius 12px +row: 352×54px; padding 7px 8px 7px 4px; radius 8px +content: 340×40px; left inset 8px; right inset 12px +label: 14px/20px/400 +summary: 12px/18px/400 diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 6f339cf9a9..94b67a29c1 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -21,6 +21,7 @@ const BASE_FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/sessio const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/ui.expected.md', import.meta.url)) const TREE_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/tree.expected.md', import.meta.url)) const BRANCHLESS_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/branchless.expected.md', import.meta.url)) +const BRANCHLESS_LAYOUT_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/branchless-layout.expected.md', import.meta.url)) const STALE_CATALOG_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/stale-catalog.expected.md', import.meta.url)) const SIDEBAR_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/sidebar.expected.md', import.meta.url)) const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/nested.expected.md', import.meta.url)) @@ -400,6 +401,80 @@ describe('web e2e: persisted subagent conversation and human continuation', () = const tree = page.getByRole('tree', { name: 'Subagent sessions' }) const nestedRow = tree.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }) expect(await nestedRow.locator(':scope > *').count()).toBe(1) + const clickArea = nestedRow.locator(':scope > *') + const label = nestedRow.getByText(NESTED_LABEL, { exact: true }) + const summary = nestedRow.getByText('continuable · not running', { exact: true }) + const [treeBox, rowBox, clickAreaBox, treeStyle, rowStyle, labelStyle, summaryStyle] = await Promise.all([ + tree.boundingBox(), + nestedRow.boundingBox(), + clickArea.boundingBox(), + tree.evaluate((element) => { + const style = getComputedStyle(element) + return { gap: style.gap, padding: style.padding, radius: style.borderRadius } + }), + nestedRow.evaluate((element) => { + const style = getComputedStyle(element) + return { padding: style.padding, radius: style.borderRadius } + }), + label.evaluate((element) => { + const style = getComputedStyle(element) + return { size: style.fontSize, lineHeight: style.lineHeight, weight: style.fontWeight } + }), + summary.evaluate((element) => { + const style = getComputedStyle(element) + return { size: style.fontSize, lineHeight: style.lineHeight, weight: style.fontWeight } + }), + ]) + expect(treeBox).not.toBeNull() + expect(rowBox).not.toBeNull() + expect(clickAreaBox).not.toBeNull() + const leftInset = Math.round(clickAreaBox!.x - treeBox!.x) + const rightInset = Math.round( + treeBox!.x + treeBox!.width - clickAreaBox!.x - clickAreaBox!.width, + ) + const layout = { + menuWidth: Math.round(treeBox!.width), + menuPadding: treeStyle.padding, + menuGap: treeStyle.gap, + menuRadius: treeStyle.radius, + rowWidth: Math.round(rowBox!.width), + rowHeight: Math.round(rowBox!.height), + rowPadding: rowStyle.padding, + rowRadius: rowStyle.radius, + contentWidth: Math.round(clickAreaBox!.width), + contentHeight: Math.round(clickAreaBox!.height), + leftInset, + rightInset, + label: labelStyle, + summary: summaryStyle, + } + expect(layout).toEqual({ + menuWidth: 360, + menuPadding: '4px', + menuGap: '4px', + menuRadius: '12px', + rowWidth: 352, + rowHeight: 54, + rowPadding: '7px 8px 7px 4px', + rowRadius: '8px', + contentWidth: 340, + contentHeight: 40, + leftInset: 8, + rightInset: 12, + label: { size: '14px', lineHeight: '20px', weight: '400' }, + summary: { size: '12px', lineHeight: '18px', weight: '400' }, + }) + await compareOrRefreshGolden( + BRANCHLESS_LAYOUT_EXPECTED, + [ + `menu: ${layout.menuWidth}px; padding ${layout.menuPadding}; gap ${layout.menuGap}; radius ${layout.menuRadius}`, + `row: ${layout.rowWidth}×${layout.rowHeight}px; padding ${layout.rowPadding}; radius ${layout.rowRadius}`, + `content: ${layout.contentWidth}×${layout.contentHeight}px; left inset ${layout.leftInset}px; right inset ${layout.rightInset}px`, + `label: ${layout.label.size}/${layout.label.lineHeight}/${layout.label.weight}`, + `summary: ${layout.summary.size}/${layout.summary.lineHeight}/${layout.summary.weight}`, + ].join('\n'), + MODE, + ) await compareOrRefreshGolden( BRANCHLESS_EXPECTED, await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd), diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css b/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css index 239081c59c..88e75de7ce 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css @@ -49,42 +49,43 @@ box-sizing: border-box; display: flex; flex-direction: column; - width: 336px; + gap: 4px; + width: 360px; max-width: min(400px, calc(100vw - 32px)); - max-height: min(560px, calc(100vh - 140px)); + max-height: calc(100vh - 140px); padding: 4px; overflow: auto; - border: 1px solid var(--dsw-alias-border-l2); + border: 0; border-radius: 12px; background: var(--dsw-specific-menu); --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); - box-shadow: var(--dsw-shadow-lv3); + box-shadow: inset 0 0 0 1px var(--dsw-alias-border-inverted), var(--dsw-shadow-lv3); } .node { position: relative; + display: flex; + flex-direction: column; + gap: 4px; min-width: 0; } -.menu > .node { - margin-left: -8px; -} .row { position: relative; display: flex; align-items: flex-start; - gap: 8px; + gap: 4px; box-sizing: border-box; width: 100%; - min-height: 50px; - padding: 7px 8px 7px 11px; + min-height: 54px; + padding: 7px 8px 7px 4px; border: 0; border-radius: 8px; background: transparent; color: var(--dsw-alias-label-primary); - font-size: 13px; - line-height: 18px; + font-size: 14px; + line-height: 20px; text-align: left; cursor: pointer; outline: none; @@ -101,16 +102,26 @@ flex: 1; align-self: stretch; align-items: flex-start; - gap: 8px; + gap: 6px; min-width: 0; - margin: -7px -8px -7px; - padding: 7px 8px; - border-radius: 8px; + border-radius: 12px; } -.row > :global([data-state]), .clickarea > :global([data-state]) { - margin-top: 4px; + margin: 5px 3px 0; +} + +.row:has(> .disclosure) > .clickarea, +.row:has(> .disclosureSpace) > .clickarea { + padding-left: 18px; +} + +.row:has(> .disclosureSpace):not(:has(> .clickarea)) { + padding-left: 22px; +} + +.row > :global([data-state]) { + margin: 5px 3px 0; } .disabled { @@ -128,9 +139,16 @@ .disclosure, .disclosureSpace { + position: absolute; + top: 7px; + left: 4px; flex: none; width: 14px; - height: 18px; + height: 20px; +} + +.disclosureSpace { + display: none; } .disclosure { @@ -157,6 +175,7 @@ display: flex; flex: 1; flex-direction: column; + gap: 2px; min-width: 0; } @@ -169,19 +188,21 @@ .label { color: inherit; + font-size: 14px; font-weight: 400; + line-height: 20px; } .summary, .metrics { color: var(--dsw-alias-label-tertiary); - font-size: 11px; - line-height: 16px; + font-size: 12px; + line-height: 18px; } .metrics { display: grid; - grid-template-rows: 18px 16px; + grid-template-rows: 20px 18px; flex: none; font-variant-numeric: tabular-nums; text-align: right; @@ -190,7 +211,7 @@ .metricToken { grid-row: 1; - line-height: 18px; + line-height: 20px; } .metricDuration { @@ -199,6 +220,9 @@ .children { position: relative; + display: flex; + flex-direction: column; + gap: 4px; margin-left: 18px; padding-left: 4px; } From 5884a44e6ba2b53ef2081e3554b14dabdaaf54f6 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Mon, 3 Aug 2026 02:43:27 -0700 Subject: [PATCH 45/52] docs(ui): clarify approval wait ownership --- packages/client/ui-conversation/README.i18n.yaml | 4 ++-- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- packages/client/ui-workspace/README.i18n.yaml | 4 ++-- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 09e17a4371..da6ea1a597 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: e1dbe7d4d5992b6b5b029fddfc9d9857ccae7443 -README.zh.md: fd82ad65f8e903a6f7106e8b8ff8eccbf1435957 +README.md: 78572ba0ab3ce9475dba31dee8844017564e2a18 +README.zh.md: 7708e980e24f4ea4365fbbacd641a5be6c61b138 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index e1dbe7d4d5..78572ba0ab 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -10,7 +10,7 @@ The resident conversation shell survives no-session and session transitions. Wit The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. -Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. +Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager tracks this approval wait through the `waitingApproval` list bit even for uninstantiated sessions; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index fd82ad65f8..7708e980e2 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -32,7 +32,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);bash 示例是第三方姿态的范例。Trajectory/waterfall(瀑布式事件)工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 -审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission `,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。 +审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 通过 `waitingApproval` 列表位跟踪这种审批等待,未实例化的 Session 也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission `,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。 todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 597b036340..374efd0f58 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md -README.md: 09370a56d891044d14212992b63323448f0d4420 -README.zh.md: 7c6e9fee286fee172fe8476ad6ab294d45a8ece1 +README.md: 17105f9d70ab5fa0c0472c4b3fb39b759107f469 +README.zh.md: b40b9469271e539501a8f6fc0b70a84f8961f7ab diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 09370a56d8..17105f9d70 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -12,7 +12,7 @@ Workspace and Session hover cards copy the value their row clips: activating a W The Session row's Fork action forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child; a trailing ASCII or fullwidth parenthesized number is incremented in the same style, while an unnumbered title gets ` (1)` appended. The source and child always appear as peer rows within a workspace group, with lineage retained only as session data. A fork or rename failure leaves the current selection unchanged; after a rename failure, the created child remains in the list. -Session rows distinguish the runtime's live `waitingApproval` approval-request fact from an otherwise blue in-flight Session: an amber warning dot takes precedence over the running indicator, and the hover card reports **Waiting for approval** until the request is resolved. Every lit state carries a visually hidden label (`Waiting for approval` or `Running`) for assistive technology; an idle row leaves the reserved status slot empty. Question waits are tracked separately and do not set `waitingApproval`. +Session rows distinguish the runtime's live `waitingApproval` approval-request fact from an otherwise blue in-flight Session: an amber warning dot takes precedence over the running indicator, and the hover card reports **Waiting for approval** until the request is resolved. Every lit state carries a visually hidden label (`Waiting for approval` or `Running`) for assistive technology; an idle row leaves the reserved status slot empty. Question waits do not set a list-level status bit such as `waitingApproval`. Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 7c6e9fee28..b40b946927 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -12,7 +12,7 @@ Workspace 和 Session 悬浮卡片会复制对应行被截断的值:激活 Wor Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork,在 client 端递增继承的持久化标题后再打开子会话;尾部半角或全角括号编号会原样式递增,无编号标题追加 ` (1)`。源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。Fork 或改名失败都不会改变当前选中项,改名失败时已创建的子会话仍会留在列表中。 -Session 行会把 runtime 的实时 `waitingApproval` 审批请求状态与原本显示为蓝色的进行中 Session 区分开:琥珀色警告点优先于运行指示器,hover 卡片则在请求解决前显示**等待审批**。每种点亮状态都带有面向辅助技术的视觉隐藏标签(等待审批或进行中,随词典本地化);空闲行会保留空的状态槽位。问题等待由另一套状态跟踪,不会设置 `waitingApproval`。 +Session 行会把 runtime 的实时 `waitingApproval` 审批请求状态与原本显示为蓝色的进行中 Session 区分开:琥珀色警告点优先于运行指示器,hover 卡片则在请求解决前显示**等待审批**。每种点亮状态都带有面向辅助技术的视觉隐藏标签(等待审批或进行中,随词典本地化);空闲行会保留空的状态槽位。问题等待不会设置如 `waitingApproval` 这样的列表级状态位。 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 From 6585847144db642bd89e8de8d0300172cc175817 Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 3 Aug 2026 18:24:25 +0800 Subject: [PATCH 46/52] fix(web): preserve subagent catalog styling --- .../branchless-layout.expected.md | 5 -- apps/web/tests/subagent-conversation.e2e.ts | 74 ++----------------- .../client/SubagentCatalogAction.module.css | 70 ++++++------------ 3 files changed, 28 insertions(+), 121 deletions(-) delete mode 100644 apps/web/tests/snapshots/subagent-conversation/branchless-layout.expected.md diff --git a/apps/web/tests/snapshots/subagent-conversation/branchless-layout.expected.md b/apps/web/tests/snapshots/subagent-conversation/branchless-layout.expected.md deleted file mode 100644 index 962e68b808..0000000000 --- a/apps/web/tests/snapshots/subagent-conversation/branchless-layout.expected.md +++ /dev/null @@ -1,5 +0,0 @@ -menu: 360px; padding 4px; gap 4px; radius 12px -row: 352×54px; padding 7px 8px 7px 4px; radius 8px -content: 340×40px; left inset 8px; right inset 12px -label: 14px/20px/400 -summary: 12px/18px/400 diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 94b67a29c1..0049cb2791 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -21,7 +21,6 @@ const BASE_FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/sessio const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/ui.expected.md', import.meta.url)) const TREE_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/tree.expected.md', import.meta.url)) const BRANCHLESS_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/branchless.expected.md', import.meta.url)) -const BRANCHLESS_LAYOUT_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/branchless-layout.expected.md', import.meta.url)) const STALE_CATALOG_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/stale-catalog.expected.md', import.meta.url)) const SIDEBAR_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/sidebar.expected.md', import.meta.url)) const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/nested.expected.md', import.meta.url)) @@ -402,79 +401,16 @@ describe('web e2e: persisted subagent conversation and human continuation', () = const nestedRow = tree.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }) expect(await nestedRow.locator(':scope > *').count()).toBe(1) const clickArea = nestedRow.locator(':scope > *') - const label = nestedRow.getByText(NESTED_LABEL, { exact: true }) - const summary = nestedRow.getByText('continuable · not running', { exact: true }) - const [treeBox, rowBox, clickAreaBox, treeStyle, rowStyle, labelStyle, summaryStyle] = await Promise.all([ + const [treeBox, clickAreaBox] = await Promise.all([ tree.boundingBox(), - nestedRow.boundingBox(), clickArea.boundingBox(), - tree.evaluate((element) => { - const style = getComputedStyle(element) - return { gap: style.gap, padding: style.padding, radius: style.borderRadius } - }), - nestedRow.evaluate((element) => { - const style = getComputedStyle(element) - return { padding: style.padding, radius: style.borderRadius } - }), - label.evaluate((element) => { - const style = getComputedStyle(element) - return { size: style.fontSize, lineHeight: style.lineHeight, weight: style.fontWeight } - }), - summary.evaluate((element) => { - const style = getComputedStyle(element) - return { size: style.fontSize, lineHeight: style.lineHeight, weight: style.fontWeight } - }), ]) expect(treeBox).not.toBeNull() - expect(rowBox).not.toBeNull() expect(clickAreaBox).not.toBeNull() - const leftInset = Math.round(clickAreaBox!.x - treeBox!.x) - const rightInset = Math.round( - treeBox!.x + treeBox!.width - clickAreaBox!.x - clickAreaBox!.width, - ) - const layout = { - menuWidth: Math.round(treeBox!.width), - menuPadding: treeStyle.padding, - menuGap: treeStyle.gap, - menuRadius: treeStyle.radius, - rowWidth: Math.round(rowBox!.width), - rowHeight: Math.round(rowBox!.height), - rowPadding: rowStyle.padding, - rowRadius: rowStyle.radius, - contentWidth: Math.round(clickAreaBox!.width), - contentHeight: Math.round(clickAreaBox!.height), - leftInset, - rightInset, - label: labelStyle, - summary: summaryStyle, - } - expect(layout).toEqual({ - menuWidth: 360, - menuPadding: '4px', - menuGap: '4px', - menuRadius: '12px', - rowWidth: 352, - rowHeight: 54, - rowPadding: '7px 8px 7px 4px', - rowRadius: '8px', - contentWidth: 340, - contentHeight: 40, - leftInset: 8, - rightInset: 12, - label: { size: '14px', lineHeight: '20px', weight: '400' }, - summary: { size: '12px', lineHeight: '18px', weight: '400' }, - }) - await compareOrRefreshGolden( - BRANCHLESS_LAYOUT_EXPECTED, - [ - `menu: ${layout.menuWidth}px; padding ${layout.menuPadding}; gap ${layout.menuGap}; radius ${layout.menuRadius}`, - `row: ${layout.rowWidth}×${layout.rowHeight}px; padding ${layout.rowPadding}; radius ${layout.rowRadius}`, - `content: ${layout.contentWidth}×${layout.contentHeight}px; left inset ${layout.leftInset}px; right inset ${layout.rightInset}px`, - `label: ${layout.label.size}/${layout.label.lineHeight}/${layout.label.weight}`, - `summary: ${layout.summary.size}/${layout.summary.lineHeight}/${layout.summary.weight}`, - ].join('\n'), - MODE, - ) + expect([ + Math.round(clickAreaBox!.x - treeBox!.x), + Math.round(treeBox!.x + treeBox!.width - clickAreaBox!.x - clickAreaBox!.width), + ]).toEqual([5, 5]) await compareOrRefreshGolden( BRANCHLESS_EXPECTED, await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd), diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css b/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css index 88e75de7ce..fc3ddfea46 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css @@ -49,43 +49,42 @@ box-sizing: border-box; display: flex; flex-direction: column; - gap: 4px; - width: 360px; + width: 336px; max-width: min(400px, calc(100vw - 32px)); - max-height: calc(100vh - 140px); + max-height: min(560px, calc(100vh - 140px)); padding: 4px; overflow: auto; - border: 0; + border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; background: var(--dsw-specific-menu); --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); - box-shadow: inset 0 0 0 1px var(--dsw-alias-border-inverted), var(--dsw-shadow-lv3); + box-shadow: var(--dsw-shadow-lv3); } .node { position: relative; - display: flex; - flex-direction: column; - gap: 4px; min-width: 0; } +.menu > .node { + margin-left: -3px; +} .row { position: relative; display: flex; align-items: flex-start; - gap: 4px; + gap: 8px; box-sizing: border-box; width: 100%; - min-height: 54px; - padding: 7px 8px 7px 4px; + min-height: 50px; + padding: 7px 8px 7px 11px; border: 0; border-radius: 8px; background: transparent; color: var(--dsw-alias-label-primary); - font-size: 14px; - line-height: 20px; + font-size: 13px; + line-height: 18px; text-align: left; cursor: pointer; outline: none; @@ -102,26 +101,16 @@ flex: 1; align-self: stretch; align-items: flex-start; - gap: 6px; + gap: 8px; min-width: 0; - border-radius: 12px; + margin: -7px -8px -7px; + padding: 7px 8px; + border-radius: 8px; } +.row > :global([data-state]), .clickarea > :global([data-state]) { - margin: 5px 3px 0; -} - -.row:has(> .disclosure) > .clickarea, -.row:has(> .disclosureSpace) > .clickarea { - padding-left: 18px; -} - -.row:has(> .disclosureSpace):not(:has(> .clickarea)) { - padding-left: 22px; -} - -.row > :global([data-state]) { - margin: 5px 3px 0; + margin-top: 4px; } .disabled { @@ -139,16 +128,9 @@ .disclosure, .disclosureSpace { - position: absolute; - top: 7px; - left: 4px; flex: none; width: 14px; - height: 20px; -} - -.disclosureSpace { - display: none; + height: 18px; } .disclosure { @@ -175,7 +157,6 @@ display: flex; flex: 1; flex-direction: column; - gap: 2px; min-width: 0; } @@ -188,21 +169,19 @@ .label { color: inherit; - font-size: 14px; font-weight: 400; - line-height: 20px; } .summary, .metrics { color: var(--dsw-alias-label-tertiary); - font-size: 12px; - line-height: 18px; + font-size: 11px; + line-height: 16px; } .metrics { display: grid; - grid-template-rows: 20px 18px; + grid-template-rows: 18px 16px; flex: none; font-variant-numeric: tabular-nums; text-align: right; @@ -211,7 +190,7 @@ .metricToken { grid-row: 1; - line-height: 20px; + line-height: 18px; } .metricDuration { @@ -220,9 +199,6 @@ .children { position: relative; - display: flex; - flex-direction: column; - gap: 4px; margin-left: 18px; padding-left: 4px; } From fc5ee9f786fb15b612474202f97ec83ec2c6fdfd Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 3 Aug 2026 18:34:00 +0800 Subject: [PATCH 47/52] refactor(client): expose loopback state through connection --- ...versioned-gui-welcome-onboarding.i18n.yaml | 4 ++-- ...-07-30-versioned-gui-welcome-onboarding.md | 2 +- ...-30-versioned-gui-welcome-onboarding.zh.md | 2 +- packages/client/connection/README.i18n.yaml | 4 ++-- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- packages/client/connection/package.json | 1 - .../client/connection/src/client/index.ts | 7 ++++++- .../connection/src/loopback-hostname.ts | 7 ++----- .../connection/tests/client-apply.spec.ts | 20 +++++++++++++------ .../client/runtime/tests/client-apply.spec.ts | 1 + .../client/runtime/tests/wire-events.spec.ts | 1 + packages/client/tsdown.client.ts | 2 +- .../ui-settings-general/src/client/index.ts | 7 +------ .../ui-settings-general/tests/apply.spec.ts | 11 ++++------ scripts/client-bundle-purity.spec.ts | 1 - 16 files changed, 38 insertions(+), 36 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml index 6a109260c1..10de05ea42 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md -2026-07-30-versioned-gui-welcome-onboarding.md: 1199cec532dd23930c70b236e6fcac80832204ff -2026-07-30-versioned-gui-welcome-onboarding.zh.md: d59e8971689fad1836127fea79cd392d3d5787c3 +2026-07-30-versioned-gui-welcome-onboarding.md: 4707769d4fa9fbf184e09a2e73087dfd326070be +2026-07-30-versioned-gui-welcome-onboarding.zh.md: c9d2e6274c476c59abc077aa3255ffe57a8a48bc diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md index 1199cec532..4707769d4f 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md @@ -14,7 +14,7 @@ The GUI's credential onboarding begins with a DeepSeek-specific readiness check, **Ownerless product onboarding belongs to `ui-settings-general`.** `src/onboarding-copy.ts` is the single editable source for the complete notice, the Continue label, and `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese owner copy. Runtime locale dictionaries derive their welcome values from that file, and tests import the same owner instead of repeating paragraph text. The notice is browser UI only: it creates no Session event and contributes no model-visible content. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out. -**Loopback acknowledgement is durable per Harness profile.** The Host half registers a `ui-onboarding` section in the user-settings seam, stored under the active `$DSH_HOME/settings.yaml`. A loopback browser shows the notice unless `welcomeNoticeVersion` equals the owner constant exactly. Continue applies one path mutation with the current version and calls `complete()` only after the Host commits it; a failed write leaves the notice open, and closing the page or process writes nothing. Bumping the constant intentionally makes every profile acknowledge the revised copy once. A non-loopback browser must not call the loopback-only settings API. It presents the same notice, but explicit Continue completes the step only in the current browser process; reload or a new process presents it again. +**Loopback acknowledgement is durable per Harness profile.** The Host half registers a `ui-onboarding` section in the user-settings seam, stored under the active `$DSH_HOME/settings.yaml`. The connection plugin publishes whether the current page uses a loopback authority as `ctx.connection.isLoopback`; hostname classification remains internal to the connection package, and other client plugins consume the service state instead of importing its implementation. A loopback browser shows the notice unless `welcomeNoticeVersion` equals the owner constant exactly. Continue applies one path mutation with the current version and calls `complete()` only after the Host commits it; a failed write leaves the notice open, and closing the page or process writes nothing. Bumping the constant intentionally makes every profile acknowledge the revised copy once. A non-loopback browser must not call the loopback-only settings API. It presents the same notice, but explicit Continue completes the step only in the current browser process; reload or a new process presents it again. **Concurrent loopback views converge without stale replacement.** The acknowledgement write omits `expectedRevision` deliberately: every loopback tab writes the same version to one path, so the operation is idempotent and preserves sibling fields instead of rebuilding the section. `settings/document-updated` becomes `host/settings-changed`; an already mounted loopback tab refetches and advances when another tab or an external editor commits the current version. The API proxy exposes this one product namespace through a closed allowlist beside configurable-provider namespaces, without treating its changes as model-catalog invalidations. diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md index d59e897168..c9d2e6274c 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md @@ -14,7 +14,7 @@ GUI 的凭据引导从 DeepSeek 专用的就绪状态检查开始,但内部测 **不属于单一功能的产品引导由 `ui-settings-general` 持有。** `src/onboarding-copy.ts` 是完整通知、「继续」按钮文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文所有者文案。运行时 locale 字典从该文件派生欢迎文案,测试也导入同一个所有者,而不重复段落文本。该通知只存在于浏览器 UI:它不会创建会话事件,也不会贡献任何模型可见内容。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。 -**loopback 确认状态按 Harness profile 持久化。** 宿主端在 user-settings seam 中注册 `ui-onboarding` 分节,并存入当前 `$DSH_HOME/settings.yaml`。除非 `welcomeNoticeVersion` 与文案所有者文件中的常量精确相等,否则 loopback 浏览器会显示通知。「继续」会以当前版本执行一次路径变更,并且仅在宿主端提交成功后调用 `complete()`;写入失败时通知保持打开,关闭页面或进程则不会写入任何内容。提升该常量会有意要求每个 profile 对修订后的文案重新确认一次。非 loopback 浏览器不能调用仅限 loopback 的 settings API;它仍显示同一通知,但显式点击「继续」只会在当前浏览器进程中完成该步骤,重新加载或新进程会再次显示通知。 +**loopback 确认状态按 Harness profile 持久化。** 宿主端在 user-settings seam 中注册 `ui-onboarding` 分节,并存入当前 `$DSH_HOME/settings.yaml`。connection 插件通过 `ctx.connection.isLoopback` 统一发布当前页面是否使用 loopback authority;hostname 判定函数留在 connection 包内,其他客户端插件只消费服务状态,不跨插件导入实现函数。除非 `welcomeNoticeVersion` 与文案所有者文件中的常量精确相等,否则 loopback 浏览器会显示通知。「继续」会以当前版本执行一次路径变更,并且仅在宿主端提交成功后调用 `complete()`;写入失败时通知保持打开,关闭页面或进程则不会写入任何内容。提升该常量会有意要求每个 profile 对修订后的文案重新确认一次。非 loopback 浏览器不能调用仅限 loopback 的 settings API;它仍显示同一通知,但显式点击「继续」只会在当前浏览器进程中完成该步骤,重新加载或新进程会再次显示通知。 **并发 loopback 视图无需陈旧的整体替换即可收敛。** 确认写入有意省略 `expectedRevision`:每个 loopback 标签页都向同一路径写入相同版本,因此该操作是幂等的,并会保留同级字段,而不是重建整个分节。`settings/document-updated` 会转为 `host/settings-changed`;另一个标签页或外部编辑器提交当前版本后,已挂载的 loopback 标签页会重新拉取状态并推进。API 网关在可配置提供方 namespace 之外,通过封闭的允许列表暴露这一个产品 namespace,同时不会把它的变更视为模型目录失效事件。 diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index e159696db3..a636d8bc49 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/connection/README.md -README.md: 522ae6a14a3b4b07e7f2917133d16a5e83433f69 -README.zh.md: 4eaed862df678997b328d7a4ddcab1f5254c7c60 +README.md: f537fee3273e3b5d2411197cf1a1a6e0d34af5f9 +README.zh.md: a29d2c00e7df3f6290a03ffdad59b70b43702aca diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 522ae6a14a..f537fee327 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The dedicated `./loopback-hostname` source subpath exposes the zero-dependency predicate shared by the `/api` Host fence and browser welcome-persistence selection; client bundlers inline this source entry, while plain Node cannot load it directly, so it must remain browser-safe and dependency-free. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. +Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Loopback hostname classification stays package-internal: the `/api` Host fence uses it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. ## /api browser-trust fence diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 4eaed862df..a29d2c00e7 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。专用的 `./loopback-hostname` 源码子路径导出 `/api` Host fence 与浏览器欢迎页持久化选择共用的零依赖判定函数;客户端 bundler 会内联这一源码入口,而 plain Node 无法直接加载它,因此它必须保持浏览器安全且零依赖。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 ## /api 浏览器信任栅栏 diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index f2842ec061..d86b2bdf2a 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -19,7 +19,6 @@ "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }, - "./loopback-hostname": "./src/loopback-hostname.ts", "./src/*": "./src/*", "./package.json": "./package.json" }, diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index a7ebfbbd86..9e33e63ce2 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -8,6 +8,7 @@ import type { IApiClient } from './api.ts' import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts' import { FixtureApiClient } from './fixture.ts' import { WebApiClient } from './web-api-client.ts' +import { isLoopbackHostname } from '../loopback-hostname.ts' // ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- export type { @@ -48,6 +49,8 @@ export const inject: string[] = [] export interface ConnectionHandle { /** Shared api client (fixture or real, decided at boot from the page URL). */ readonly api: IApiClient + /** Whether the current page authority is loopback; non-browser contexts default to true. */ + readonly isLoopback: boolean /** * Start the connect/pump/reconnect loop with the consumer's frame sinks. * One consumer owns the streams (the runtime object layer); a second call @@ -64,11 +67,13 @@ export interface ConnectionHandle { * @param ctx - client cordis context. */ export function apply(ctx: Context): void { - const fixture = typeof location !== 'undefined' && new URLSearchParams(location.search).has('fixture') + const pageLocation = typeof location === 'undefined' ? undefined : location + const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture') const api: IApiClient = fixture ? new FixtureApiClient() : new WebApiClient() let started = false const handle: ConnectionHandle = { api, + isLoopback: pageLocation === undefined || isLoopbackHostname(pageLocation.hostname), start(sinks, config) { if (started) throw new Error('connection: the stream loop is already owned by another consumer') started = true diff --git a/packages/client/connection/src/loopback-hostname.ts b/packages/client/connection/src/loopback-hostname.ts index 5666f0714d..fe2fe93fc9 100644 --- a/packages/client/connection/src/loopback-hostname.ts +++ b/packages/client/connection/src/loopback-hostname.ts @@ -1,10 +1,7 @@ /** * Browser-safe, zero-dependency loopback classification shared by the `/api` - * Host fence and browser welcome-persistence selection. The dedicated - * `./loopback-hostname` source subpath is inlined into client bundles instead - * of loaded by plain Node, so this module must not add Node-only or runtime - * dependencies. - * @module @deepseek-ai/dsh-client-connection/loopback-hostname + * Host fence and the package's `ctx.connection` state. The predicate stays + * package-internal; client plugins consume the derived state through Cordis. */ /** diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index 6892dc7721..43c71dffb7 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -8,7 +8,7 @@ import { apply, type ConnectionHandle } from '../src/client/index.ts' import { FixtureApiClient } from '../src/client/fixture.ts' import { WebApiClient } from '../src/client/web-api-client.ts' -type Win = { location?: { search: string } } +type Win = { location?: { hostname: string; search: string } } afterEach(() => { delete (globalThis as Win).location @@ -24,20 +24,28 @@ async function mount(): Promise { describe('connection client apply', () => { it('mounts ctx.connection with the real client when no ?fixture switch is present', async () => { - ;(globalThis as Win).location = { search: '' } + ;(globalThis as Win).location = { hostname: 'localhost', search: '' } const handle = await mount() expect(handle.api).toBeInstanceOf(WebApiClient) + expect(handle.isLoopback).toBe(true) }) it('selects the fixture client under ?fixture (and with no location at all stays real)', async () => { - ;(globalThis as Win).location = { search: '?fixture' } + ;(globalThis as Win).location = { hostname: '127.0.0.1', search: '?fixture' } expect((await mount()).api).toBeInstanceOf(FixtureApiClient) delete (globalThis as Win).location - expect((await mount()).api).toBeInstanceOf(WebApiClient) + const handle = await mount() + expect(handle.api).toBeInstanceOf(WebApiClient) + expect(handle.isLoopback).toBe(true) + }) + + it('reports non-loopback page authority through the connection handle', async () => { + ;(globalThis as Win).location = { hostname: '192.0.2.20', search: '' } + expect((await mount()).isLoopback).toBe(false) }) it('start() hands out one loop, rejects a second consumer, and stop() aborts the streams', async () => { - ;(globalThis as Win).location = { search: '?fixture' } + ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() // config omitted: the `config ?? {}` default arm is part of the surface. const loop = handle.start({}) @@ -46,7 +54,7 @@ describe('connection client apply', () => { }) it('WebApiClient carries requests over globalThis.fetch', async () => { - ;(globalThis as Win).location = { search: '' } + ;(globalThis as Win).location = { hostname: 'localhost', search: '' } const handle = await mount() const original = globalThis.fetch const seen: string[] = [] diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index d389efe319..e5691a0619 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -26,6 +26,7 @@ async function mount(): Promise { const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 } const handle: ConnectionHandle = { api, + isLoopback: true, start: (sinks) => { bench.sinks = sinks return { stop: () => { bench.stopped += 1 } } diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index fd7858d60c..21e7f1fc06 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -20,6 +20,7 @@ async function mount(): Promise { const bench: Bench = { ctx, sinks: undefined } const handle: ConnectionHandle = { api, + isLoopback: true, start: (sinks) => { bench.sinks = sinks return { stop: () => {} } diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index fad00fe4f0..2ff1856b3d 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -28,7 +28,7 @@ const CSS_VIRTUAL_SUFFIX = '.mjs' * Everything else under @deepseek-ai/* is either a module-table entry * (external) or a leak the purity gate rejects. */ -export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:host-apiproxy|session|llm|tools|brand)(?:\/|$)|@deepseek-ai\/dsh-client-connection\/loopback-hostname$)/ +export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/ /** * Documented TEMPORARY exemption, not a platform module (hence not in diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 6a7510d4ac..87b05e86cb 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -8,7 +8,6 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' -import { isLoopbackHostname } from '@deepseek-ai/dsh-client-connection/loopback-hostname' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' // Type-only: pulls the shell's SlotMap merges (trigger/header/section/item). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' @@ -42,10 +41,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Dictionary namespace owned by this plugin (shell chrome + General copy). */ const NS = 'settings' -function welcomePersistence(): 'host' | 'memory' { - return typeof location === 'undefined' || isLoopbackHostname(location.hostname) ? 'host' : 'memory' -} - /** * Required services (cordis fiber inject). The target slots are declared by * ui-settings' apply, whose activation order relative to this one is NOT @@ -66,7 +61,7 @@ export function apply(ctx: ClientContext): void { // locale/change re-registration wiring. const t = ctx.locale.bind(NS) const connection = ctx.get('connection') as ConnectionHandle - const welcomeController = new WelcomeNoticeStore(connection.api, welcomePersistence()) + const welcomeController = new WelcomeNoticeStore(connection.api, connection.isLoopback ? 'host' : 'memory') const useWelcomeSnapshot = bindSnapshotSelector(welcomeController.store) const welcomeInjected = (): WelcomeNoticeInjected => ({ controller: welcomeController, diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.spec.ts index 17e4ced2b2..73f6d8207e 100644 --- a/packages/client/ui-settings-general/tests/apply.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.spec.ts @@ -1,6 +1,6 @@ /** Ownerless-copy registrations: the four seats, the dictionaries, thunked labels, and HMR recovery. */ import { Context } from 'cordis' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' @@ -16,8 +16,6 @@ import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts' // the shipped Chinese copy, so they state the browser they assume. usePinnedBrowserLanguages('zh-CN') -afterEach(() => { vi.unstubAllGlobals() }) - /** The five seats this plugin fills (slot name → expected component). */ const SEATS = [ ['settings.trigger', TriggerContent], @@ -27,7 +25,7 @@ const SEATS = [ ['settings.onboarding', WelcomeNotice], ] as const -async function bench() { +async function bench(isLoopback = true) { const ctx = new Context() await ctx.plugin(SlotsService).await() const locale = new LocaleService(ctx) @@ -49,7 +47,7 @@ async function bench() { }, }, })) - ctx.provide('connection', { api: { settings: { describe: settingsDescribe } } } as never) + ctx.provide('connection', { api: { settings: { describe: settingsDescribe } }, isLoopback } as never) return { ctx, slots: ctx.get('slots') as SlotsService, locale, settingsDescribe } } @@ -162,8 +160,7 @@ describe('ui-settings-general apply', () => { }) it('keeps remote welcome acknowledgement process-local', async () => { - vi.stubGlobal('location', { hostname: '192.0.2.20' }) - const b = await bench() + const b = await bench(false) declare(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const entry = b.slots.entries('settings.onboarding')[0]! diff --git a/scripts/client-bundle-purity.spec.ts b/scripts/client-bundle-purity.spec.ts index 2eef1ba5ef..d70964bdba 100644 --- a/scripts/client-bundle-purity.spec.ts +++ b/scripts/client-bundle-purity.spec.ts @@ -53,7 +53,6 @@ describe('client bundle purity gate', () => { expect(resolveId('@deepseek-ai/dsh-host-apiproxy/api')).toBeNull() expect(resolveId('@deepseek-ai/dsh-session/surface')).toBeNull() expect(resolveId('@deepseek-ai/dsh-brand')).toBeNull() - expect(resolveId('@deepseek-ai/dsh-client-connection/loopback-hostname')).toBeNull() }) it('throws on any other @deepseek-ai leak', () => { From 3770d947cf0c6b16ecd640d3e44e4e6bf683fcab Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 3 Aug 2026 18:43:18 +0800 Subject: [PATCH 48/52] docs(web): correct welcome acknowledgement contract --- .../client/ui-settings-general/src/client/WelcomeNotice.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/ui-settings-general/src/client/WelcomeNotice.tsx b/packages/client/ui-settings-general/src/client/WelcomeNotice.tsx index 0c381e02c9..c25b1bf3aa 100644 --- a/packages/client/ui-settings-general/src/client/WelcomeNotice.tsx +++ b/packages/client/ui-settings-general/src/client/WelcomeNotice.tsx @@ -31,7 +31,7 @@ export interface WelcomeNoticeInjected { export type WelcomeNoticeProps = PropsRuntime<'settings.onboarding'> & PropsLocale<'settings'> & WelcomeNoticeInjected -/** Render the mandatory notice until its current version commits durably. */ +/** Render the mandatory notice until its current version is acknowledged. */ export function WelcomeNotice(props: WelcomeNoticeProps): ReactNode { const { complete, controller, useSnapshot, t } = props const state = useSnapshot(snapshot => snapshot) From aec9e3145e078ecb3078b19a0c839378584f8adc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:53:25 +0800 Subject: [PATCH 49/52] chore: enable Issue management automation --- .github/issue-management/policy.mjs | 16 ++++++- .github/issue-management/policy.test.mjs | 19 ++++++++ .github/workflows/issue-lifecycle.yml | 58 ++++++++++++++++++++++++ .github/workflows/issue-policy.yml | 27 +++++++++++ 4 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/issue-lifecycle.yml create mode 100644 .github/workflows/issue-policy.yml diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs index bc8c881eda..4c9242bab5 100644 --- a/.github/issue-management/policy.mjs +++ b/.github/issue-management/policy.mjs @@ -180,6 +180,20 @@ export function parseReferences({ body, repository }) { } } +/** + * Retain only references that resolve to Issues rather than pull requests. + * @param {{all: number[], resolving: number[], related: number[]}} references Parsed references. + * @param {Map} issues Resolved same-repository Issues. + * @returns {{all: number[], resolving: number[], related: number[]}} Issue-only references. + */ +export function retainIssueReferences(references, issues) { + return { + all: references.all.filter((number) => issues.has(number)), + resolving: references.resolving.filter((number) => issues.has(number)), + related: references.related.filter((number) => issues.has(number)), + } +} + /** * Validate one Issue with its Project status. * @param {{title: string, body: string, assignees: string[], labels: string[], type: string|null, priority: string|null, status: string|null, state: string, stateReason: string|null}} issue Issue snapshot. @@ -472,7 +486,7 @@ async function pullRequestSnapshot(number) { reviewRequestCount: reviewRequests.users.length + reviewRequests.teams.length, reviewCount: reviews.length, labels: pull.labels.map((label) => label.name), - references, + references: retainIssueReferences(references, issues), issues, } } diff --git a/.github/issue-management/policy.test.mjs b/.github/issue-management/policy.test.mjs index 890247db29..8e0c253796 100644 --- a/.github/issue-management/policy.test.mjs +++ b/.github/issue-management/policy.test.mjs @@ -4,6 +4,7 @@ import test from 'node:test' import { countVisibleUnits, parseReferences, + retainIssueReferences, requiresPullRequestPolicy, validateBody, validateIssue, @@ -117,6 +118,24 @@ test('separates resolving and informational references', () => { ) }) +test('does not treat pull request references as Issue associations', () => { + const references = { + all: [123, 1180, 1181], + resolving: [123, 1180], + related: [1181], + } + const issues = new Map([ + [1180, {}], + [1181, {}], + ]) + + assert.deepEqual(retainIssueReferences(references, issues), { + all: [1180, 1181], + resolving: [1180], + related: [1181], + }) +}) + test('allows informational references without cross-object constraints', () => { const errors = validatePullRequest({ isDraft: false, diff --git a/.github/workflows/issue-lifecycle.yml b/.github/workflows/issue-lifecycle.yml new file mode 100644 index 0000000000..4dc6869e27 --- /dev/null +++ b/.github/workflows/issue-lifecycle.yml @@ -0,0 +1,58 @@ +name: Issue lifecycle + +on: + issues: + types: + - opened + - edited + - assigned + - unassigned + - labeled + - unlabeled + - closed + - reopened + - field_added + - field_removed + pull_request: + types: + - opened + - edited + - synchronize + - reopened + - labeled + - unlabeled + - ready_for_review + - review_requested + pull_request_review: + types: + - submitted + +permissions: + contents: read + +concurrency: + group: issue-lifecycle-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }} + cancel-in-progress: false + +jobs: + lifecycle: + name: Issue lifecycle + runs-on: ubuntu-latest + steps: + - name: Check out trusted policy + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + - name: Create project token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 + with: + client-id: ${{ vars.DSH_ISSUE_APP_CLIENT_ID }} + private-key: ${{ secrets.DSH_ISSUE_APP_PRIVATE_KEY }} + owner: deepseek-harness + repositories: deepseek-harness + - name: Handle repository event + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: node .github/issue-management/policy.mjs lifecycle diff --git a/.github/workflows/issue-policy.yml b/.github/workflows/issue-policy.yml new file mode 100644 index 0000000000..dde9462c33 --- /dev/null +++ b/.github/workflows/issue-policy.yml @@ -0,0 +1,27 @@ +name: Issue policy + +on: + pull_request: + types: [opened, edited, synchronize, reopened, labeled, unlabeled, ready_for_review, review_requested] + pull_request_review: + types: [submitted] + +permissions: + contents: read + issues: read + pull-requests: read + +jobs: + policy: + name: Issue policy + runs-on: ubuntu-latest + steps: + - name: Check out trusted policy + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + - name: Validate pull request + env: + GITHUB_TOKEN: ${{ github.token }} + run: node .github/issue-management/policy.mjs pr From 7a26214a817fbafdc43aaef5c79c6d07cfd29074 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 3 Aug 2026 21:13:52 +0800 Subject: [PATCH 50/52] feat(tui): resolve resume titles through the projection cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit session-title already registers a title projection unit; /resume now reads it instead of scanning logs: live rows from the registry snapshot, persisted rows from the durable checkpoint row (cachedSnapshot, zero I/O), and only rows without a usable checkpoint pay a coldSnapshot — checkpoint plus readFrom tail, written back so the next scan is metadata-only. Cold reads are bounded by the new resumeScanConcurrency config; compositions without the cache fall back to the bounded readTitleSnapshots batch. The TUI overlay mounts the projection registry, storage, and projection-cache rows over the same storages root the web surface uses, so checkpoints serve both. --- ...resume-selector-batch-projection.i18n.yaml | 4 +- ...-07-31-resume-selector-batch-projection.md | 8 +- ...-31-resume-selector-batch-projection.zh.md | 8 +- apps/cli/config/tui.cordis.yml | 22 +++++ docs/config-catalog.md | 4 +- packages/ui/tui/README.i18n.yaml | 4 +- packages/ui/tui/README.md | 2 +- packages/ui/tui/README.zh.md | 2 +- packages/ui/tui/package.json | 4 + packages/ui/tui/src/chat/resume.ts | 88 ++++++++++++++++--- packages/ui/tui/src/config.ts | 6 ++ packages/ui/tui/tests/tui.spec.ts | 78 ++++++++++++++++ packages/ui/tui/tsconfig.json | 6 ++ pnpm-lock.yaml | 6 ++ 14 files changed, 217 insertions(+), 25 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml index 7a51d1081c..3c6718b975 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md -2026-07-31-resume-selector-batch-projection.md: 8a0256da34b8d7de94d3f13b06fa41d591543fcf -2026-07-31-resume-selector-batch-projection.zh.md: a0357a06e95d4a7aad2a5646f9bf2b0946d5e167 +2026-07-31-resume-selector-batch-projection.md: 39146527f13b20813bb6f5d5f1349ecab5724662 +2026-07-31-resume-selector-batch-projection.zh.md: 10333ea7cc5e7f2051c37f3374c5dc061bd0586e diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md index 8a0256da34..39146527f1 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md @@ -12,13 +12,13 @@ Opening the TUI `/resume` selector called `sessionQuery.readSession()` once per Selector rows fold nothing but titles, and everything else a row shows comes from metadata: -- Titles come from the existing public batch `readTitleSnapshots` — one persistence listing, at most `persistedInspectConcurrency` concurrent inspections, per-id failure isolation. This is the selector's only per-log read; a rejected title read degrades to that row's disabled "Unreadable session" fallback. +- Titles come from the projection system: `session-title` already registers a `title` unit, so a live row reads the registry snapshot, a persisted row reads the durable checkpoint row (`sessionProjectionCache.cachedSnapshot`, zero I/O), and only a row without a usable checkpoint pays a `coldSnapshot` — checkpoint plus a `readFrom` tail, written back so the next scan is zero-I/O. Cold reads are bounded by the TUI `resumeScanConcurrency` config. A composition without the cache falls back to one bounded `readTitleSnapshots` batch over the logs; either path isolates a per-row failure into the disabled "Unreadable session" fallback. - The activity timestamp never reads a log: a live session uses its last in-memory event time; a persisted session stats the artifact named by the optional `sessionPersistence.locate()` (mtime), falling back to the header's creation time when the backend locates no per-session artifact (SQLite) or the stat fails. Any append moves the mtime, so a mere pickup boundary now floats a browsed session up — accepted as the price of a metadata-only timestamp. - The last-turn label, provider/model route, and goal phase columns are gone from rows. Route availability is now enforced by the Enter-time preflight, which fully reads and replay-validates the one chosen log through `readSession` before handoff. The selector overlay opens synchronously when `/resume` dispatches, before the scan settles: an `undefined` candidate set renders a "Loading sessions…" placeholder, the picker owns terminal input from its first frame, Enter reports that sessions are still loading, and Escape cancels. Closing the overlay aborts the scan through the `AbortSignal` the query methods accept; a signal-ignoring backend's late settlement is dropped by a staleness check. The finished scan swaps rows in through `setCandidates` (clearing a stale still-loading error) without replacing the overlay; a queued activation behind a closing predecessor receives an already-scanned set at construction; one catch spans listing, titles, and mtimes, so any scan failure closes the overlay and reports a notice rather than stranding the loading placeholder. -The change is confined to the TUI package: no session-query or session-persistence surface changed. +No session-query or session-persistence surface changed. The shipped TUI composition gains the projection registry, storage, and projection-cache rows (mirroring the web overlay over the same `storages` root, so checkpoints written by either surface serve both); the first scan over a pre-existing store still reads each log once to seed checkpoints, and every later scan is metadata-only. ## Alternatives considered @@ -28,8 +28,8 @@ The change is confined to the TUI package: no session-query or session-persisten **Surface a last-modified time through `listSnapshots`/`SessionRecord`.** Cleanest seam-wise, but touches the persistence contract, both backends, and the query record shape for what the TUI can already derive from `locate()` plus one stat. Reintroduce if a second consumer needs metadata activity times. -**A persisted summary/title index.** Rejected for now: one bounded title pass is acceptable selector latency, and an index adds an invalidation contract. Reintroduce if title reads over large stores become the bottleneck. +**A bespoke persisted title index or TUI-local title cache.** Rejected: the session-projection cache already is the owned durable checkpoint system with an invalidation contract (`stateVersion`, identity binding, shrunk-log anchoring); mounting it beats adding a parallel cache. ## Consequences -Opening `/resume` performs one listing, one stat per persisted row, and one bounded title pass instead of N listings and N validated full copies. Rows show title, timestamp, status, and id only; route problems surface as an Enter-time preflight error instead of a disabled row, and a session that fails replay is caught by preflight rather than the listing. Browsed-then-abandoned sessions float up on their pickup mtime. Fake `sessionQuery` services in TUI tests provide `readTitleSnapshots` alongside `listSessions`/`readSession`, and the test harness forwards an optional `locate`. Because the picker takes focus immediately, starting a second scan requires dismissing the current overlay first — a second `/resume` typed during a scan lands in the search field, which is the intended input capture. +Opening `/resume` performs one listing, one stat per persisted row, and per-row title reads that touch only checkpoint rows and log tails once checkpoints exist — O(session count) metadata instead of O(total log bytes); the fallback path without the cache remains one bounded title pass. Rows show title, timestamp, status, and id only; route problems surface as an Enter-time preflight error instead of a disabled row, and a session that fails replay is caught by preflight rather than the listing. Browsed-then-abandoned sessions float up on their pickup mtime. Fake `sessionQuery` services in TUI tests provide `readTitleSnapshots` alongside `listSessions`/`readSession`, and the test harness forwards an optional `locate`. Because the picker takes focus immediately, starting a second scan requires dismissing the current overlay first — a second `/resume` typed during a scan lands in the search field, which is the intended input capture. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md index a0357a06e9..10333ea7cc 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md @@ -12,13 +12,13 @@ Status: implemented 选择器行除标题外不折叠任何内容,行内其余信息全部来自元数据: -- 标题来自既有的公开批量 `readTitleSnapshots`——一次持久化列表查询、最多 `persistedInspectConcurrency` 个并发检查、按 id 隔离失败。这是选择器唯一的按日志读取;标题读取被拒绝时退化为该行的禁用"Unreadable session"回退。 +- 标题来自投影系统:`session-title` 已注册 `title` 投影单元,因此实时行读取注册表快照,持久化行读取持久 checkpoint 行(`sessionProjectionCache.cachedSnapshot`,零 I/O),只有没有可用 checkpoint 的行才付出一次 `coldSnapshot`——checkpoint 加 `readFrom` 尾部折叠,并写回使下次扫描零 I/O。冷读取受 TUI `resumeScanConcurrency` 配置约束。未挂载缓存的组合回退到一次对日志的有界 `readTitleSnapshots` 批量读取;两条路径都把单行失败隔离为禁用的"Unreadable session"回退。 - 活动时间戳从不读取日志:实时会话取内存中最后一个事件的时间;持久化会话对可选 `sessionPersistence.locate()` 命名的产物做 stat(mtime),当后端定位不到按会话的产物(SQLite)或 stat 失败时回退到 header 的创建时间。任何追加都会移动 mtime,因此仅仅一次 pickup 边界也会让浏览过的会话上浮——这是元数据时间戳的代价,予以接受。 - 行内不再有最后轮次标签、提供方/模型路由和目标阶段列。路由可用性改由 Enter 时的预检强制:预检通过 `readSession` 完整读取并回放验证选中的那一份日志后才移交。 选择器 overlay 在 `/resume` 分发时同步打开,早于扫描结算:`undefined` 候选集渲染"Loading sessions…"加载占位符,选择器从第一帧起就拥有终端输入,Enter 提示会话仍在加载,Escape 取消。关闭 overlay 会通过查询方法接受的 `AbortSignal` 中止扫描;忽略信号的后端的迟到结算由过期检查丢弃。扫描完成后通过 `setCandidates`(同时清除过期的仍在加载错误)换入行数据,不替换 overlay;排在正在关闭的前任之后的排队激活会在构造时直接收到已扫描的集合;列表查询、标题与 mtime 共用同一个 catch,因此任何扫描失败都会关闭 overlay 并报告通知,而不会让加载占位符悬置。 -改动局限于 TUI 包:session-query 与 session-persistence 的任何表面都未改变。 +session-query 与 session-persistence 的任何表面都未改变。随附的 TUI 组合新增投影注册表、storage 与投影缓存行(镜像 web overlay,共用同一 `storages` 根,因此任一表面写下的 checkpoint 都服务两者);对既有存储的首次扫描仍会各读取一次日志以播种 checkpoint,之后的每次扫描都只读元数据。 ## Alternatives considered @@ -28,8 +28,8 @@ Status: implemented **通过 `listSnapshots`/`SessionRecord` 暴露最后修改时间。** 从接缝角度最干净,但要触碰持久化契约、两个后端和查询记录形状,而 TUI 已能用 `locate()` 加一次 stat 得到同样的信息。若出现第二个需要元数据活动时间的消费者再引入。 -**持久化摘要/标题索引。** 暂时否决:一次有界标题扫描的选择器延迟可接受,而索引会引入失效契约。若大型存储上的标题读取成为瓶颈再引入。 +**专门的持久化标题索引或 TUI 本地标题缓存。** 否决:session-projection 缓存本身就是自有的持久 checkpoint 系统,并已带失效契约(`stateVersion`、身份绑定、日志收缩锚定);挂载它优于再造一套并行缓存。 ## Consequences -打开 `/resume` 只执行一次列表查询、每个持久化行一次 stat、一次有界标题扫描,而不是 N 次列表查询和 N 份经验证的完整副本。行内只显示标题、时间戳、状态和 id;路由问题以 Enter 时预检错误的形式出现,而不再是禁用行;回放会失败的会话由预检而非列表阶段拦截。浏览后放弃的会话会因 pickup 的 mtime 上浮。TUI 测试中的伪造 `sessionQuery` 服务在 `listSessions`/`readSession` 之外提供 `readTitleSnapshots`,测试 harness 会转发可选的 `locate`。由于选择器立即接管焦点,启动第二次扫描需要先关闭当前 overlay——扫描期间输入的第二个 `/resume` 会落入搜索字段,这正是预期的输入捕获行为。 +打开 `/resume` 只执行一次列表查询、每个持久化行一次 stat,标题读取在 checkpoint 就绪后只触碰 checkpoint 行和日志尾部——O(会话数) 的元数据开销,而非 O(日志总字节数);无缓存的回退路径仍是一次有界标题扫描。行内只显示标题、时间戳、状态和 id;路由问题以 Enter 时预检错误的形式出现,而不再是禁用行;回放会失败的会话由预检而非列表阶段拦截。浏览后放弃的会话会因 pickup 的 mtime 上浮。TUI 测试中的伪造 `sessionQuery` 服务在 `listSessions`/`readSession` 之外提供 `readTitleSnapshots`,测试 harness 会转发可选的 `locate`。由于选择器立即接管焦点,启动第二次扫描需要先关闭当前 overlay——扫描期间输入的第二个 `/resume` 会落入搜索字段,这正是预期的输入捕获行为。 diff --git a/apps/cli/config/tui.cordis.yml b/apps/cli/config/tui.cordis.yml index 02d8649447..02b048648f 100644 --- a/apps/cli/config/tui.cordis.yml +++ b/apps/cli/config/tui.cordis.yml @@ -77,6 +77,28 @@ - id: session-reference name: '@deepseek-ai/dsh-session-reference' + # The projection registry plus its durable checkpoint cache (over the same + # storage root the web surface uses): `/resume` reads titles from the + # zero-I/O checkpoint row or a tail-only cold read instead of scanning + # whole logs, and checkpoints written by either surface serve both. + - id: session-projection + name: '@deepseek-ai/dsh-session-projection' + - id: storage + name: '@deepseek-ai/dsh-storage' + - id: storage-json + name: '@deepseek-ai/dsh-storage-json' + config: + root: !!js dshHomePath('storages') + - id: storage-domain + name: '@deepseek-ai/dsh-storage-domain' + config: + backend: json + - id: session-projection-cache + name: '@deepseek-ai/dsh-session-projection-cache' + config: + writeEveryEvents: 200 + writeIntervalMs: 5000 + # Terminal-multiplexer context, mounted only where a terminal exists. - id: tmux-context name: '@deepseek-ai/dsh-tmux-context' diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 617cfb82be..6a055d8820 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2075,6 +2075,8 @@ export interface TuiConfig { maxModelOptions?: number /** Maximum sessions visible at once in the resume selector. */ maxResumeOptions?: number + /** Maximum concurrent cold projection reads in one resume scan. */ + resumeScanConcurrency?: number /** User-question panel width in terminal columns, clamped to the terminal. */ questionDialogWidth?: number /** User-question panel maximum height in terminal rows. */ @@ -2116,7 +2118,7 @@ export interface TuiThemeConfig { } ``` -Source: [`packages/ui/tui/src/config.ts:125`](../packages/ui/tui/src/config.ts) +Source: [`packages/ui/tui/src/config.ts:129`](../packages/ui/tui/src/config.ts) ## `@deepseek-ai/dsh-typert-loader` diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index 9e0b716705..15b5333aa1 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/tui/README.md -README.md: 5b497be0c849c83c37879ded52ba8bb2031715f8 -README.zh.md: a5841d6c37209811da6e3f6eb8526c01281de710 +README.md: ac45a0ec9c282f3c872b325fe30a083dd1deed33 +README.zh.md: 6bcb431713dd8a247d2b39d3392edc369ad00c90 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 5b497be0c8..ac45a0ec9c 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -34,7 +34,7 @@ The footer sums the session's reported usage as `↑ `/resume` opens a full-viewport keyboard selector instead of a centered dialog. The selector opens as soon as the command runs and takes input focus while the session scan is still pending, showing a loading placeholder until the rows arrive; Escape cancels an in-flight scan the same way it cancels the loaded list. Two scopes cover the same candidate set: the current workspace, which it opens on, and all workspaces, which Tab toggles to. The scope line under the search field names the active scope and the count the other holds, and each row in the all-workspaces scope also reports its own workspace. Toggling clears the search and selection so the highlighted row always belongs to the visible list. -Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Rows fold nothing but each log's title (one bounded batch read): candidates are sorted by metadata activity — a live session's last in-memory event time, otherwise the persisted artifact's mtime, falling back to creation time — and searchable by title or session id, and by workspace label in the all-workspaces scope; each row reports that timestamp plus current/live/persisted state and the id. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, or a session with no recorded workspace to run in remains visible but disabled; a workspace other than the current one is a scope rather than a disabled reason, because resume enters that directory. +Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Rows read no whole logs: when the optional projection cache is mounted, titles come from the live projection registry or the durable checkpoint row, with a cold read folding only the log tail since the checkpoint (written back so the next scan is zero-I/O, bounded by `resumeScanConcurrency`); a composition without the cache falls back to one bounded batch title read over the logs. Candidates are sorted by metadata activity — a live session's last in-memory event time, otherwise the persisted artifact's mtime, falling back to creation time — and searchable by title or session id, and by workspace label in the all-workspaces scope; each row reports that timestamp plus current/live/persisted state and the id. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, or a session with no recorded workspace to run in remains visible but disabled; a workspace other than the current one is a scope rather than a disabled reason, because resume enters that directory. Selection repeats those checks, fully reads and replay-validates the one chosen log, rejects it when its logged provider has no current adapter, and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume` with the selected id and the workspace re-read at preflight: process cwd, not the restored session header, is what filesystem and shell tools resolve against, so the host must enter that directory. Where `process.execve` is available, the shipped `dsh` host chdirs into it before disposing the app and replacing its process, and rejects an unreachable directory while the terminal can still be restored. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`. diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index a5841d6c37..6bcb431713 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -34,7 +34,7 @@ Footer 将会话报告的用量汇总为 `↑`;任 `/resume` 会打开全 viewport 键盘选择器,而非居中对话框。选择器在命令执行时立即打开并接管输入焦点,会话扫描仍在进行时显示加载占位符,直到行数据就绪;Escape 取消进行中的扫描,方式与取消已加载列表相同。两个作用域覆盖同一候选项集合:打开时所处的当前工作区,以及按 Tab 切换到的所有工作区。搜索字段下方的作用域行会给出当前作用域的名称以及另一个作用域包含的数量,且在所有工作区作用域中每行还会报告自身所属的工作区。切换会清除搜索与选择,使高亮行始终属于可见列表。 -获得焦点的搜索字段紧跟搜索 glyph 开始,并发出 pi-tui 的 cursor marker,使终端 IME 组合保持锚定在字段内。行数据除每份日志的标题(一次有界批量读取)外不折叠任何内容:候选项按元数据活动时间排序——实时会话取内存中最后一个事件的时间,否则取持久化产物的 mtime,再回退到创建时间——可按标题或会话 id 搜索,在所有工作区作用域中还可按工作区标签搜索;每行报告该时间戳、current/live/persisted 状态和 id。Up/Down 与 Page Up/Page Down 导航,Enter 恢复,Escape 会先清除非空搜索,再次按下才取消,Ctrl+C 则直接取消。当前会话、已在本运行时中活跃的会话、不可读日志,或没有可运行的已记录工作区的会话仍会显示,但不可选择;不同于当前工作区的工作区属于作用域而非禁用原因,因为恢复会进入该目录。 +获得焦点的搜索字段紧跟搜索 glyph 开始,并发出 pi-tui 的 cursor marker,使终端 IME 组合保持锚定在字段内。行数据不读取任何完整日志:挂载可选的投影缓存时,标题来自实时投影注册表或持久化 checkpoint 行,冷读取只折叠 checkpoint 之后的日志尾部(并写回,使下次扫描零 I/O,受 `resumeScanConcurrency` 约束);未挂载缓存的组合回退到一次对日志的有界批量标题读取。候选项按元数据活动时间排序——实时会话取内存中最后一个事件的时间,否则取持久化产物的 mtime,再回退到创建时间——可按标题或会话 id 搜索,在所有工作区作用域中还可按工作区标签搜索;每行报告该时间戳、current/live/persisted 状态和 id。Up/Down 与 Page Up/Page Down 导航,Enter 恢复,Escape 会先清除非空搜索,再次按下才取消,Ctrl+C 则直接取消。当前会话、已在本运行时中活跃的会话、不可读日志,或没有可运行的已记录工作区的会话仍会显示,但不可选择;不同于当前工作区的工作区属于作用域而非禁用原因,因为恢复会进入该目录。 选择时会重复这些检查,完整读取并回放验证所选中的那一份日志,在其日志所记提供方没有当前适配器时拒绝,并要求当前 agent 空闲,随后 flush 当前会话。TUI 接着停止终端 UI,并以所选 id 和在预检时重新读取的工作区调用由宿主持有的可选 `TuiRuntime.handoffResume`:文件系统与 shell 工具解析所依据的是进程 cwd,而非恢复出的会话头部,因此宿主必须进入该目录。存在 `process.execve` 时,发布的 `dsh` 宿主会先 chdir 进入该目录,再对 app 执行 dispose 并替换自身进程,并在终端仍可恢复时拒绝不可达的目录。恢复操作保留相同的 `SessionId`、transcript、标题、todo 和持久目标;目标激活仍保持解除,TUI 会要求用户确认或执行 `/goal resume`。 diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json index a68fe7968a..135774a846 100644 --- a/packages/ui/tui/package.json +++ b/packages/ui/tui/package.json @@ -42,6 +42,8 @@ "@deepseek-ai/dsh-goal": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", + "@deepseek-ai/dsh-session-projection": "^0.0.1", + "@deepseek-ai/dsh-session-projection-cache": "^0.0.1", "@deepseek-ai/dsh-session-query": "^0.0.1", "@deepseek-ai/dsh-session-reference": "^0.0.1", "@deepseek-ai/dsh-session-title": "^0.0.1", @@ -82,6 +84,8 @@ "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", diff --git a/packages/ui/tui/src/chat/resume.ts b/packages/ui/tui/src/chat/resume.ts index 6105fb781d..f2173862ae 100644 --- a/packages/ui/tui/src/chat/resume.ts +++ b/packages/ui/tui/src/chat/resume.ts @@ -11,6 +11,9 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import { errorChain } from '@deepseek-ai/dsh-llm' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-session-persistence' +import type {} from '@deepseek-ai/dsh-session-projection' +import type { SessionProjectionCache } from '@deepseek-ai/dsh-session-projection-cache' +import type {} from '@deepseek-ai/dsh-session-title' import type { SessionQueryService, SessionRecord, @@ -114,6 +117,73 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro } } + /** + * One persisted row's title through the projection-cache ladder: the + * zero-I/O checkpoint row when usable, otherwise a cold read that folds + * only the log tail since the checkpoint and writes the refreshed row + * back — so a store scanned once serves later scans without log reads. + */ + const projectedTitle = async ( + cache: SessionProjectionCache, + record: SessionRecord, + signal: AbortSignal, + ): Promise => { + const live = ctx.sessions.get(record.header.id) + if (live !== undefined) return ctx.get('sessionProjections')?.snapshot(live).values.title + const cached = cache.cachedSnapshot(record.header) + if (cached !== undefined && 'title' in cached.values) return cached.values.title + return (await cache.coldSnapshot(record.header.id, signal)).values.title + } + + /** One per-record title resolution: a title (absent for untitled) or an isolated failure. */ + type TitleResolution = { title?: string; failure?: unknown } + + /** + * Resolve every row's title without reading whole logs when the projection + * cache is mounted (live registry snapshot / checkpoint row / tail-only + * cold read, bounded by `resumeScanConcurrency`); a composition without + * the cache falls back to one bounded raw-log title batch. + */ + const resolveTitles = async ( + listQuery: SessionQueryService, + records: readonly SessionRecord[], + signal: AbortSignal, + ): Promise => { + const cache = ctx.get('sessionProjectionCache') + if (cache === undefined) { + const results = await listQuery.readTitleSnapshots(records.map(record => record.header.id), signal) + return records.map((record, index): TitleResolution => { + const result = results[index] + /* v8 ignore next 2 -- readTitleSnapshots returns one result per unique listed id in input order */ + if (result === undefined || result.sessionId !== record.header.id) throw new Error(`resume scan misaligned at "${record.header.id}"`) + if (result.status === 'rejected') return { failure: result.reason } + const title = result.value.title?.title + return title === undefined ? {} : { title } + }) + } + const resolutions = new Array(records.length) + let cursor = 0 + const worker = async (): Promise => { + for (;;) { + const index = cursor + if (index >= records.length) return + cursor += 1 + const record = records[index] as SessionRecord + try { + const value = await projectedTitle(cache, record, signal) + resolutions[index] = typeof value === 'string' ? { title: value } : {} + } catch (failure: unknown) { + resolutions[index] = { failure } + } + } + } + await Promise.all(Array.from( + { length: Math.min(resolved.resumeScanConcurrency, records.length) }, + () => worker(), + )) + return resolutions + } + /** The latest logged provider/model route, for the preflight availability check. */ const resumeRoute = (events: readonly SessionEvent[]): { provider: string; model: string } | undefined => { const header = events.findLast(item => item.type === 'request/header') @@ -266,20 +336,18 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro // current-workspace/all-workspaces scope split over the whole set. const records = await listQuery.listSessions(scanAbort.signal) if (scanStale()) return - // Rows need only metadata, an mtime, and the batch-folded title — the - // one per-log read the selector performs. A corrupt neighbor degrades - // to one disabled row. + // Rows need only metadata, an mtime, and a title — resolved without + // whole-log reads when the projection cache is mounted. A corrupt + // neighbor degrades to one disabled row. const [titles, activity] = await Promise.all([ - listQuery.readTitleSnapshots(records.map(record => record.header.id), scanAbort.signal), + resolveTitles(listQuery, records, scanAbort.signal), Promise.all(records.map(record => lastActivityAt(record))), ]) const candidates = records.map((record, index) => { - const title = titles[index] - /* v8 ignore next 2 -- readTitleSnapshots returns one result per unique listed id in input order */ - if (title === undefined || title.sessionId !== record.header.id) throw new Error(`resume scan misaligned at "${record.header.id}"`) - return title.status === 'fulfilled' - ? summarize(record, title.value.title?.title, activity[index]) - : unreadableCandidate(record, activity[index], title.reason) + const resolution = titles[index] as TitleResolution + return 'failure' in resolution + ? unreadableCandidate(record, activity[index], resolution.failure) + : summarize(record, resolution.title, activity[index]) }) candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt || a.record.header.id.localeCompare(b.record.header.id)) diff --git a/packages/ui/tui/src/config.ts b/packages/ui/tui/src/config.ts index 8b07629af8..28ec85edb6 100644 --- a/packages/ui/tui/src/config.ts +++ b/packages/ui/tui/src/config.ts @@ -42,6 +42,8 @@ export interface TuiConfig { maxModelOptions?: number /** Maximum sessions visible at once in the resume selector. */ maxResumeOptions?: number + /** Maximum concurrent cold projection reads in one resume scan. */ + resumeScanConcurrency?: number /** User-question panel width in terminal columns, clamped to the terminal. */ questionDialogWidth?: number /** User-question panel maximum height in terminal rows. */ @@ -72,6 +74,7 @@ const maxDiffEditLengthSchema = z.number().step(1).min(1).default(1000) const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8) const maxModelOptionsSchema = z.number().step(1).min(1).default(8) const maxResumeOptionsSchema = z.number().step(1).min(1).default(8) +const resumeScanConcurrencySchema = z.number().step(1).min(1).default(4) const questionDialogWidthSchema = z.number().step(1).min(20).default(200) const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20) const modelDialogWidthSchema = z.number().step(1).min(20).default(76) @@ -105,6 +108,7 @@ const tuiConfigSchemaFields = { maxQuestionOptions: maxQuestionOptionsSchema, maxModelOptions: maxModelOptionsSchema, maxResumeOptions: maxResumeOptionsSchema, + resumeScanConcurrency: resumeScanConcurrencySchema, questionDialogWidth: questionDialogWidthSchema, questionDialogMaxHeight: questionDialogMaxHeightSchema, modelDialogWidth: modelDialogWidthSchema, @@ -178,6 +182,7 @@ export interface ResolvedTuiConfig { maxQuestionOptions: number maxModelOptions: number maxResumeOptions: number + resumeScanConcurrency: number questionDialogWidth: number questionDialogMaxHeight: number modelDialogWidth: number @@ -205,6 +210,7 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf maxQuestionOptions: config?.maxQuestionOptions ?? 8, maxModelOptions: config?.maxModelOptions ?? 8, maxResumeOptions: config?.maxResumeOptions ?? 8, + resumeScanConcurrency: config?.resumeScanConcurrency ?? 4, questionDialogWidth: config?.questionDialogWidth ?? 200, questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20, modelDialogWidth: config?.modelDialogWidth ?? 76, diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index d03291f274..78712c10fb 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -191,6 +191,7 @@ describe('TUI config', () => { maxQuestionOptions: 8, maxModelOptions: 8, maxResumeOptions: 8, + resumeScanConcurrency: 4, questionDialogWidth: 200, questionDialogMaxHeight: 20, modelDialogWidth: 76, @@ -217,6 +218,7 @@ describe('TUI config', () => { maxQuestionOptions: 3, maxModelOptions: 4, maxResumeOptions: 5, + resumeScanConcurrency: 2, questionDialogWidth: 60, questionDialogMaxHeight: 14, modelDialogWidth: 64, @@ -235,6 +237,7 @@ describe('TUI config', () => { maxQuestionOptions: 3, maxModelOptions: 4, maxResumeOptions: 5, + resumeScanConcurrency: 2, questionDialogWidth: 60, questionDialogMaxHeight: 14, modelDialogWidth: 64, @@ -494,6 +497,81 @@ describe('goodbye message and /resume', () => { await dispose(result) }) + it('resolves titles through the projection cache without scanning logs', async () => { + const current = header('main-session', 5, '/workspace') + const cachedRow = header('cached-title', 40, '/workspace') + const rowless = header('rowless-title', 30, '/workspace') + const untitled = header('untitled-title', 20, '/workspace') + const broken = header('broken-title', 10, '/workspace') + let coldReads = 0 + const result = await setup({ + cwd: '/workspace', + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.provide('sessionQuery', { + listSessions: () => Promise.resolve([ + { header: current, live: true, persisted: false }, + { header: cachedRow, live: false, persisted: true }, + { header: rowless, live: false, persisted: true }, + { header: untitled, live: false, persisted: true }, + { header: broken, live: false, persisted: true }, + ]), + readTitleSnapshots: () => Promise.reject(new Error('the ladder must not scan logs')), + } as never) + ctx.provide('sessionProjections', { + snapshot: () => ({ asOfSeq: 0, values: { title: 'Live projected' } }), + } as never) + ctx.provide('sessionProjectionCache', { + cachedSnapshot: (meta: SessionHeader) => { + if (meta.id === cachedRow.id) return { asOfSeq: 3, values: { title: 'Cached projected' } } + if (meta.id === untitled.id) return { asOfSeq: 3, values: { title: null } } + if (meta.id === rowless.id) return { asOfSeq: 3, values: {} } + return undefined + }, + coldSnapshot: async (id: SessionId) => { + coldReads += 1 + if (id === broken.id) throw new Error('checkpoint restore failed') + return { asOfSeq: 5, values: { title: 'Cold projected' } } + }, + } as never) + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('Live projected') + expect(result.terminal.output).toContain('Cached projected') + expect(result.terminal.output).toContain('Cold projected') + expect(result.terminal.output).toContain('Untitled session') + expect(result.terminal.output).toContain('Unreadable session') + expect(result.terminal.output).toContain('checkpoint restore failed') + expect(result.terminal.output).not.toContain('the ladder must not scan logs') + expect(coldReads).toBe(2) + await dispose(result) + }) + + it('shows a live row untitled when the cache is mounted without the registry', async () => { + const current = header('main-session', 5, '/workspace') + const result = await setup({ + cwd: '/workspace', + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.provide('sessionQuery', { + listSessions: () => Promise.resolve([{ header: current, live: true, persisted: false }]), + } as never) + ctx.provide('sessionProjectionCache', { + cachedSnapshot: () => undefined, + coldSnapshot: async () => ({ asOfSeq: -1, values: {} }), + } as never) + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('Untitled session') + await dispose(result) + }) + it('orders rows by artifact mtime without reading logs for the timestamp', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-resume-mtime-')) const stale = join(dir, 'stale.log') diff --git a/packages/ui/tui/tsconfig.json b/packages/ui/tui/tsconfig.json index eddddcc704..06a36adc28 100644 --- a/packages/ui/tui/tsconfig.json +++ b/packages/ui/tui/tsconfig.json @@ -32,6 +32,12 @@ { "path": "../../session-persistence/session-persistence" }, + { + "path": "../../session-projection/session-projection" + }, + { + "path": "../../session-projection/session-projection-cache" + }, { "path": "../../session-query/session-query" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e27760b0d..cc4c582a93 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5881,6 +5881,12 @@ importers: '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session-projection/session-projection + '@deepseek-ai/dsh-session-projection-cache': + specifier: workspace:^ + version: link:../../session-projection/session-projection-cache '@deepseek-ai/dsh-session-query': specifier: workspace:^ version: link:../../session-query/session-query From fea88291c1ccb8941c1cc5df5ec8aebdea2772e4 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 3 Aug 2026 21:29:29 +0800 Subject: [PATCH 51/52] docs: regenerate module graph for tui projection deps --- docs/module-graph.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 8419646dca..5f77800b48 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -939,6 +939,8 @@ flowchart TD pkg_tui --> pkg_llm_retry pkg_tui --> pkg_session pkg_tui --> pkg_session_persistence + pkg_tui --> pkg_session_projection + pkg_tui --> pkg_session_projection_cache pkg_tui --> pkg_session_query pkg_tui --> pkg_session_reference pkg_tui --> pkg_session_title @@ -1237,7 +1239,7 @@ flowchart TD | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`repository-plugin`](../packages/cordis/repository-plugin) | `cordis` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | From 414c310324b7700f8e9e29251a3164ade385a94a Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 3 Aug 2026 22:04:53 +0800 Subject: [PATCH 52/52] cleanup(cli): omit invariants from shipped configs --- ...-package-owned-invariant-service.i18n.yaml | 6 ++-- ...6-07-19-package-owned-invariant-service.md | 8 ++--- ...7-19-package-owned-invariant-service.zh.md | 8 ++--- ...t-invariants-from-shipped-config.i18n.yaml | 6 ++++ ...-03-omit-invariants-from-shipped-config.md | 30 +++++++++++++++++++ ...-omit-invariants-from-shipped-config.zh.md | 30 +++++++++++++++++++ apps/cli/config/tui.cordis.yml | 13 -------- apps/cli/package.json | 1 - apps/cli/tests/built-bin.e2e.ts | 4 +++ pnpm-lock.yaml | 3 -- 10 files changed, 81 insertions(+), 28 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-08-03-omit-invariants-from-shipped-config.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-08-03-omit-invariants-from-shipped-config.md create mode 100644 .agents/notes/implemented/simplification/2026-08-03-omit-invariants-from-shipped-config.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml index a3e8c3ad8a..3c005a6deb 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-19-package-owned-invariant-service.md: 2443a8f7d04b96f51bb798130078a7457f78b2a1 -2026-07-19-package-owned-invariant-service.zh.md: 3c71d3b7f99a507d4c0236b7ef6dc0794814cdc8 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md +2026-07-19-package-owned-invariant-service.md: e32efe9f6b3ce6b782c61db56d928e87c160dc9a +2026-07-19-package-owned-invariant-service.zh.md: 60edaa3f6009acc516017683232ca0c07f64ec0d diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md index 2443a8f7d0..e32efe9f6b 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md @@ -8,7 +8,7 @@ English | [中文](2026-07-19-package-owned-invariant-service.zh.md) Runtime invariant checks span session traces, agent state, scoped dispatch, and request reconstruction. Putting all checks in one diagnostics package makes that package import product vocabularies from unrelated domains, centralizes tests away from their owners, and requires the central package to change whenever a product package adds or removes a check. -Deployments also need more than presence or absence of one plugin. A standard composition should carry the known invariant contributions while permitting a global off switch and package-selective diagnostics. Selection must remain stable when a package loads later or reloads under HMR, and disabled contributions must not allow two plugins to claim the same package name silently. +Deployments that opt into diagnostics need more than presence or absence of one plugin. Such a composition carries the known invariant contributions while permitting a global off switch and package-selective diagnostics. Selection must remain stable when a package loads later or reloads under HMR, and disabled contributions must not allow two plugins to claim the same package name silently. Package ownership must also be exhaustive. Without a mechanical repository rule, a new package can omit the companion, dependency, or publication wiring and remain invisible to diagnostics until a maintainer notices the gap. @@ -72,9 +72,9 @@ These four owners supplied the initial stateful checks. The follow-up runtime-co The generated scoped-event subject resolver lives in `dsh-scope`, beside the contract and invariant that consume it. `gen-scoped-events` uses the root TypeScript Program to enumerate `this: Scoped` declarations, infer routing-key types from real `scopeTarget(base, key)` calls, and require one unambiguous payload subject or an explicit unsupported marker. The committed runtime map imports no event-owner package, so semantic completeness does not expand either the service or scope package's runtime closure. -### Standard composition and SDK output +### Example composition and SDK output -The standard agent spine mounts the service and all four stateful companion subpaths, forwarding `enabled`, `package_allowlist`, and `package_blocklist` to the service. Generated SDK Cordis composition emits the same entries. A subpath entry adds its installable root npm package rather than treating the subpath as a package name. +The example agent spine mounts the service and all four stateful companion subpaths, forwarding `enabled`, `package_allowlist`, and `package_blocklist` to the service. Generated SDK Cordis composition emits the same entries. A subpath entry adds its installable root npm package rather than treating the subpath as a package name. The shipped `dsh` TUI and Web config trees omit the service and companions under the [shipped-config decision](../simplification/2026-08-03-omit-invariants-from-shipped-config.md). Workspace constraints recognize the separate invariant bundle, and package exports, project references, build configuration, dependency declarations, and the lockfile describe the same publication surface. Generated config catalogs, module graphs, and API documentation derive from those sources. @@ -97,7 +97,7 @@ Every Vitest configuration loads a test host that mounts an explicitly enabled s - Product packages own and test their relational assertions while the service stays product-independent. - Every package pays the publication and dependency cost of a companion; only owners with a meaningful runtime relationship add listener or trace-state cost. -- Standard compositions can disable all checks or select package names without changing their plugin tree. +- Compositions that mount the diagnostics can disable all checks or select package names without changing their plugin tree. - Explicit companion entries make diagnostic cost and ownership visible in Cordis config and package exports. - One selected executable contribution adds one child fiber and its listener/state cost; a selected empty contribution has no listener or trace-state cost, while filtered registrations retain only name ownership. - Regex sources are deployment configuration and remain fixed until the service reloads. diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md index 3c71d3b7f9..60edaa3f60 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md @@ -8,7 +8,7 @@ Status: implemented 运行时不变式检查跨越会话轨迹、agent 状态、作用域 dispatch 和请求重建。如果所有检查都放在一个诊断包中,该包就必须导入彼此无关的产品领域词汇,测试也会离开真正的所有者;任何产品包新增或移除检查时,都要修改中央包。 -部署还需要比“是否加载一个插件”更细的控制。标准组合应携带已知的不变式贡献,同时允许全局关闭或按包选择诊断。包稍后加载或在 HMR 下重载时,选择结果必须保持稳定;被过滤的贡献也不能让两个插件静默占用同一个包名。 +选择启用诊断的部署还需要比“是否加载一个插件”更细的控制。这类组合会携带已知的不变式贡献,同时允许全局关闭或按包选择诊断。包稍后加载或在 HMR 下重载时,选择结果必须保持稳定;被过滤的贡献也不能让两个插件静默占用同一个包名。 包所有权还必须覆盖完整。若没有机械化的仓库规则,新包可能遗漏伴随插件、依赖或发布配置,并一直不会进入诊断范围,直到维护者发现这一缺口。 @@ -72,9 +72,9 @@ blocklist 匹配优先于 allowlist 匹配。每个条目都是区分大小写 生成的 scoped event 主体解析表位于 `dsh-scope`,与消费它的契约和不变式相邻。`gen-scoped-events` 使用根 TypeScript Program 枚举 `this: Scoped` 声明,从真实 `scopeTarget(base, key)` 调用推断路由键类型,并要求唯一、无歧义的 payload 主体或显式 unsupported 标记。提交的运行时映射不导入事件所有者包,因此语义完整性不会扩大服务包或 scope 包的运行时依赖闭包。 -### 标准组合与 SDK 输出 +### 示例组合与 SDK 输出 -标准 agent spine 会挂载服务和四个有状态伴随子路径,并把 `enabled`、`package_allowlist` 与 `package_blocklist` 转发给服务。生成的 SDK Cordis 组合输出相同条目。子路径条目添加可安装的根 npm 包,而不会把子路径误当成包名。 +示例 agent spine 会挂载服务和四个有状态伴随子路径,并把 `enabled`、`package_allowlist` 与 `package_blocklist` 转发给服务。生成的 SDK Cordis 组合输出相同条目。子路径条目添加可安装的根 npm 包,而不会把子路径误当成包名。根据[交付配置决策](../simplification/2026-08-03-omit-invariants-from-shipped-config.md),交付的 `dsh` TUI 与 Web 配置树会省略该服务及其伴随插件。 Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、构建配置、依赖声明和 lockfile 描述同一发布表面。生成的配置目录、模块图和 API 文档都从这些源派生。 @@ -97,7 +97,7 @@ Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、 - 产品包拥有并测试自己的关系断言,服务保持与产品无关。 - 每个包都承担 companion 的发布与依赖成本;只有具备有意义运行时关系的所有者才增加 listener 或 trace 状态成本。 -- 标准组合无需改变插件树即可关闭全部检查或按包名选择。 +- 挂载诊断的组合无需改变插件树即可关闭全部检查或按包名选择。 - 显式伴随条目让诊断成本和所有权在 Cordis 配置与包 export 中可见。 - 每个选中的可执行贡献增加一个子 fiber 及其 listener/状态成本;选中的空贡献不增加 listener 或 trace 状态成本,被过滤注册则只保留包名占用。 - 正则表达式源属于部署配置,在服务重载前保持固定。 diff --git a/.agents/notes/implemented/simplification/2026-08-03-omit-invariants-from-shipped-config.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-03-omit-invariants-from-shipped-config.i18n.yaml new file mode 100644 index 0000000000..91b9e6a0c0 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-03-omit-invariants-from-shipped-config.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-03-omit-invariants-from-shipped-config.md +2026-08-03-omit-invariants-from-shipped-config.md: ff9a3b0ab2b4797ca4e96bea9e6b961b2e38501f +2026-08-03-omit-invariants-from-shipped-config.zh.md: 526ce0756e69b36b6f54b46b23e1814322d6a1fd diff --git a/.agents/notes/implemented/simplification/2026-08-03-omit-invariants-from-shipped-config.md b/.agents/notes/implemented/simplification/2026-08-03-omit-invariants-from-shipped-config.md new file mode 100644 index 0000000000..ff9a3b0ab2 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-03-omit-invariants-from-shipped-config.md @@ -0,0 +1,30 @@ +# Agent Note: Omit runtime invariants from shipped dsh config + +Status: implemented + +English | [中文](2026-08-03-omit-invariants-from-shipped-config.zh.md) + +## Problem + +`@deepseek-ai/dsh-invariants` and package-owned `./invariant` companions are optional development diagnostics. The shipped TUI mounted the service and four stateful companions while the shipped Web tree omitted them, so the two product surfaces had different diagnostic cost and failure behavior. A relational assertion failure could terminate an ordinary TUI run even though the always-on product boundary remained responsible for session validation and immutable history. + +## Decision + +The shipped `dsh` configuration trees under `apps/cli/config/` mount neither `@deepseek-ai/dsh-invariants` nor any package-owned `./invariant` companion. The CLI package therefore carries no direct dependency on the invariant service. + +Invariant support remains available for focused tests, example bundles, generated SDK compositions, and custom deployments that opt into diagnostics explicitly. Session validation, snapshotting, freezing, and provenance remain always on and do not depend on the optional service, as defined by the [source-owned immutability decision](../architecture/2026-06-11-dev-invariants-over-deep-readonly.md). + +The built CLI config-dump test checks both shipped surfaces and rejects either the service entry or any `@deepseek-ai/dsh-*/invariant` entry. + +## Alternatives considered + +- **Mount the service with `enabled: false`.** Rejected because the shipped tree and CLI dependency would still carry diagnostics that install no checks. +- **Keep the TUI-only mount.** Rejected because the shipped surfaces would retain different diagnostic and failure behavior. +- **Remove invariant support from the repository.** Rejected because package-owned checks remain useful in tests, examples, generated SDKs, and explicit development compositions; only the default product config is out of scope. + +## Consequences + +- Ordinary `dsh` TUI and Web runs install no invariant listeners or trace state and cannot fail through `InvariantError`. +- Development and custom compositions retain explicit access to the invariant service and companions. +- The shipped config absence is verified from the built CLI's composed output for both surfaces. +- Always-on session integrity remains unchanged. diff --git a/.agents/notes/implemented/simplification/2026-08-03-omit-invariants-from-shipped-config.zh.md b/.agents/notes/implemented/simplification/2026-08-03-omit-invariants-from-shipped-config.zh.md new file mode 100644 index 0000000000..526ce0756e --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-03-omit-invariants-from-shipped-config.zh.md @@ -0,0 +1,30 @@ +# Agent Note: 从交付的 dsh 配置中省略运行时不变式 + +Status: implemented + +[English](2026-08-03-omit-invariants-from-shipped-config.md) | 中文 + +## 问题 + +`@deepseek-ai/dsh-invariants` 与各包(package)拥有的 `./invariant` 伴随插件是可选的开发诊断。交付的 TUI 挂载了该服务和四个有状态伴随插件,而交付的 Web 配置树省略了这些条目,导致两个产品 surface 的诊断成本和失败行为不同。即使始终启用的产品边界仍负责会话验证与不可变历史,关系断言失败也可能终止普通的 TUI 运行。 + +## 决策 + +`apps/cli/config/` 下交付的 `dsh` 配置树既不挂载 `@deepseek-ai/dsh-invariants`,也不挂载任何包拥有的 `./invariant` 伴随插件。因此,CLI 包不再直接依赖不变式服务。 + +不变式支持仍可供聚焦测试、示例组合包、生成的 SDK 组合,以及显式选择诊断的自定义部署使用。会话验证、快照、冻结和 provenance 始终启用,且不依赖可选服务,具体由[源端拥有的不可变性决策](../architecture/2026-06-11-dev-invariants-over-deep-readonly.md)规定。 + +构建后 CLI 的配置转储测试会检查两个交付的 surface,并拒绝服务条目或任何 `@deepseek-ai/dsh-*/invariant` 条目。 + +## 已考虑的替代方案 + +- **挂载服务并设置 `enabled: false`。** 不予采纳,因为交付的配置树和 CLI 依赖仍会携带不安装任何检查的诊断。 +- **保留仅由 TUI 挂载的方案。** 不予采纳,因为两个交付的 surface 仍会保留不同的诊断和失败行为。 +- **从仓库中移除不变式支持。** 不予采纳,因为包拥有的检查在测试、示例、生成的 SDK 及显式开发组合中仍然有用;只有默认产品配置不在其范围内。 + +## 后果 + +- 普通的 `dsh` TUI 与 Web 运行不安装不变式监听器或 trace 状态,也不会因 `InvariantError` 失败。 +- 开发和自定义组合仍可显式使用不变式服务及伴随插件。 +- 构建后 CLI 的组合输出会验证两个 surface 的交付配置中均不存在这些条目。 +- 始终启用的会话完整性保持不变。 diff --git a/apps/cli/config/tui.cordis.yml b/apps/cli/config/tui.cordis.yml index 02d8649447..68243bedce 100644 --- a/apps/cli/config/tui.cordis.yml +++ b/apps/cli/config/tui.cordis.yml @@ -58,19 +58,6 @@ # ── TUI-only rows ─────────────────────────────────────────────────────────── - insert: - # Relational runtime checks over the authoritative event streams; each - # companion registers the assertions its own package owns. - - id: invariants - name: '@deepseek-ai/dsh-invariants' - - id: session-invariant - name: '@deepseek-ai/dsh-session/invariant' - - id: agent-invariant - name: '@deepseek-ai/dsh-agent/invariant' - - id: scope-invariant - name: '@deepseek-ai/dsh-scope/invariant' - - id: agent-loop-invariant - name: '@deepseek-ai/dsh-agent-loop/invariant' - # The derived query index behind `/resume`. The launcher provides a unique # process-local path because this SQLite backend has one writer owner; the # project-local fallback applies when no launcher sets the typed slot. diff --git a/apps/cli/package.json b/apps/cli/package.json index 8c482ac3e2..9bba751b27 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -65,7 +65,6 @@ "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 3592d438dd..88f8178fba 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -94,6 +94,8 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).toContain('model: deepseek-v4-pro') expect(stdout).toContain('cwd: !!js process.cwd()') expect(stdout).toContain("name: '@deepseek-ai/dsh-tui'") + expect(stdout).not.toMatch(/name: ['"]@deepseek-ai\/dsh-invariants['"]/) + expect(stdout).not.toMatch(/name: ['"]@deepseek-ai\/dsh-[^'"]+\/invariant['"]/) expect(stdout).toContain([ '- id: tool-web', " name: '@deepseek-ai/dsh-tool-web'", @@ -140,6 +142,8 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(code).toBe(0) expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'") expect(stdout).not.toContain("name: '@deepseek-ai/dsh-tui'") + expect(stdout).not.toMatch(/name: ['"]@deepseek-ai\/dsh-invariants['"]/) + expect(stdout).not.toMatch(/name: ['"]@deepseek-ai\/dsh-[^'"]+\/invariant['"]/) }, 30_000) }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e27760b0d..6b8b964f31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -276,9 +276,6 @@ importers: '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../packages/host/webserver - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../packages/support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../packages/llm/llm