diff --git a/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.i18n.yaml new file mode 100644 index 0000000000..3087777feb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.i18n.yaml @@ -0,0 +1,25 @@ +# Bilingual-pair consistency record for 2026-10-11-chat-group-tree-exchanges.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.md +/agent-note-chat-group-tree-positions-and-exchanges: + en: b26212eda152d6c6 + zh: a801a75ee0c07911 +/agent-note-chat-group-tree-positions-and-exchanges/problem: + en: 84858bc0e1e268ab + zh: 39ebb2979968d6cc +/agent-note-chat-group-tree-positions-and-exchanges/decision: + en: c8c4091863dec269 + zh: 57a0f26683867350 +/agent-note-chat-group-tree-positions-and-exchanges/why-mark-the-opening-rather-than-the-notice: + en: db8d77cd773ad646 + zh: ab2f03b8fc58e952 +/agent-note-chat-group-tree-positions-and-exchanges/alternatives-considered: + en: 635ddcb04fefe8e2 + zh: 360c171ccb613ed5 +/agent-note-chat-group-tree-positions-and-exchanges/consequences: + en: c8cfdc3009b19b12 + zh: 36c541cd9692057d +/agent-note-chat-group-tree-positions-and-exchanges/testing: + en: 0d7f5281e66b0c39 + zh: 5be221de46e9e0f7 diff --git a/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.md b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.md new file mode 100644 index 0000000000..6c3a9f0df1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.md @@ -0,0 +1,51 @@ +# Agent Note: Chat group-tree positions and exchanges + +Status: implemented + +English | [中文](2026-10-11-chat-group-tree-exchanges.zh.md) + +## Problem + +On next message folding kept only the last Turn of the loaded timeline expanded. A Turn woken by a notice (a settled subagent, goal round, schedule, or background job) is not submitted by this Client, yet its arrival made the preceding Turn historical and folded it. No own-input driver computed the exact remaining room for that fold, so the scroll layer reserved generic bottom space and a reader following the tail saw blank space. In the recorded Session the notice woke the next Turn 13–15 ms after the previous Turn ended; the minimal reproduction left 596 px blank that had not recovered after five seconds. + +## Decision + +A notice is neither body text nor a user message: folding and bottom advancement ignore it. The bottom changes from the last Turn to the latest exchange. + +- **Group-tree positions.** `ChatConversationViewNode.groupTree` declares a Node's position in the group tree, typed `ChatGroupTree = 'exchange' | 'turn' | 'process'` and ordered outermost first; a Node at a position also stands outside every inner grouping. `isAtOrOutside(node, position)` is the single reader of that order. Only `exchange` is read today; `turn` and `process` are reserved for migrating the `TURN_PROCESS_INDEPENDENT_KINDS` and process-group `INDEPENDENT` kind lists later without changing what `exchange` means. +- **Exchanges.** A Turn holding an `exchange` Node opens an exchange, which lasts until the next opening Turn. The business message Definition marks only human input claimed in Step 1 (`user` or `steering`) as `exchange`; notices and later steering carry no position. +- **Mechanism.** `GroupTreeSegments(position)` splits the loaded Turns at the Turns holding a Node at that position. The latest segment starts at the last opening Turn, or at the first loaded Turn when the window holds none. It counts per Turn incrementally without scanning loaded Nodes, and a start move returns only the Turns that entered or left the latest segment. The Chat Builder's `bottomSource` is "the Node is visible and belongs to the latest exchange". + +As a result, a notice-woken Turn no longer folds earlier Turns, so no room is reserved; the human's next ordinary message folds the whole exchange through the own-input path. + +## Why mark the opening rather than the notice + +The host claims the waking input right after `turn/start`, assembles the system prompt, and only then logs `step/start` and `user/message`; the recorded Session spent 187 ms in that gap. Marking only the notice leaves the new Turn untagged during the gap, so the bottom still advances and the previous Turn folds and then reopens. Marking the human opening and letting unmarked Turns continue the exchange keeps the gap inert. + +## Alternatives considered + +**Tag the notice as a continuation.** The 187 ms gap above makes it fold and reopen, and a claim that mixes a notice with human input needs extra classification. + +**Name opening kinds in a list (`user`, `steering`).** A kind cannot separate Step 1 steering from mid-Turn steering, so a human steering into a notice-woken Turn would wrongly fold the previous exchange. + +**Publish Turn-level Location data.** Only one Context may publish a key for a Turn, while opening input comes from several message Contexts; aggregating per Turn would repeat the inbox claim classification in a new Definition and read every Turn on each update. + +**Declare a business role (`input`, `notice`) on Nodes.** That places business semantics in the grouping mechanism; group-tree positions describe structure only, and each business Definition chooses its position. + +**Correct the scroll room for folds not caused by own input.** Notices should not fold at all, and correcting the room would not remove that unnecessary fold; it remains a residual issue for human input from another tab. + +**Migrate both kind lists now.** That reaches well beyond this defect; the type reserves the positions and the migration is separate work. + +## Consequences + +- Notice-woken Turns no longer cause a fold, reserved room, or blank space, and the grouping mechanism reads declared positions instead of naming kinds. +- Under On next message, a long run of notice-woken Turns (up to 31 goal-mode Turns in sampled Sessions) stays expanded until the human types again. +- When the loaded window holds no opening Turn, every loaded Turn belongs to the latest exchange. +- Human input from another tab folds when its `user/message` arrives, about 200 ms later than the former `turn/start` fold; that fold still uses generic room and is unchanged here. + +## Testing + +- `packages/client/ui-chat/tests/group-tree-segments.client.spec.ts`: position order, segmentation, count fallback, and the empty window. +- `packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts`: latest-exchange membership and notification scope. +- `packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts`: a real assembler across an opening Turn, mid-Turn steering, the pre-Step gap, a notice wake, and an idle steer. +- `apps/web/tests/notice-wake-tail.e2e.ts`: after a long Turn a notice wakes the next Turn; the long Turn stays expanded through the pre-Step gap and after the notice reply, and a following reader sees the notice and the reply. diff --git a/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.zh.md b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.zh.md new file mode 100644 index 0000000000..170a82fb92 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.zh.md @@ -0,0 +1,51 @@ +# Agent Note: Chat 分组树位置与交互段 + +Status: implemented + +[English](2026-10-11-chat-group-tree-exchanges.md) | 中文 + +## Problem + +「下次发送时收起」原本只让已加载时间线的最后一个 Turn 保持展开。由通知唤醒的 Turn(子 agent 完成、Goal Round、定时任务、后台作业)不是本客户端提交的,它一出现,上一轮就被当作历史折叠。这次折叠没有自己输入的驱动来计算精确的剩余补白,滚动层只能按通用规则预留底部空间,正在跟尾的读者因此看到一片空白。录制的真实 Session 中,通知在上一轮结束后 13–15 ms 唤醒新轮次;最小复现里空白高 596 px,五秒后仍未恢复。 + +## Decision + +通知不算正文,也不算用户消息:折叠和推进底部时都忽略它。底部从"最后一个 Turn"改为"最新交互段"。 + +- **分组树位置。** `ChatConversationViewNode.groupTree` 声明节点在分组树中的位置,类型为 `ChatGroupTree = 'exchange' | 'turn' | 'process'`,由外到内有序;站在某个位置的节点同时也站在所有内层分组之外。`isAtOrOutside(node, position)` 是读取这一顺序的唯一入口。目前只读取 `exchange`;`turn` 和 `process` 预留给以后把 `TURN_PROCESS_INDEPENDENT_KINDS` 与过程组 `INDEPENDENT` 两份 kind 名单迁移过来,届时不改变 `exchange` 的含义。 +- **交互段。** 含 `exchange` 节点的 Turn 开启一个交互段,交互段持续到下一个开启 Turn。业务层的 message Definition 只给 Step 1 中被认领的真人输入(`user` 或 `steering`)标 `exchange`;通知和之后的 steering 不标。 +- **机制。** `GroupTreeSegments(position)` 按含该位置节点的 Turn 切分已加载的 Turn,最新一段从最后一个开启 Turn 开始,窗口中没有开启 Turn 时从第一个已加载 Turn 开始。它按 Turn 增量计数,不扫描已加载节点;起点移动时只返回进入或离开最新一段的 Turn。Chat Builder 的 `bottomSource` 为"节点可见且属于最新交互段"。 + +结果是:通知唤醒的 Turn 不再折叠前面的 Turn,没有折叠就没有补白;真人下一次普通输入时,整个交互段经由自己输入的路径一起折叠。 + +## 为什么标开头而不是标通知 + +Host 在 `turn/start` 之后立即认领唤醒输入,然后组装系统提示,再写 `step/start` 和 `user/message`。录制的 Session 中这段间隔为 187 ms。只给通知打标的话,这段时间新 Turn 还没有标签,底部照样推进,上一轮会先折叠再展开。把标签放在真人开头上、未标的 Turn 默认续接,空白间隔里什么都不会发生。 + +## Alternatives considered + +**给通知打"续接"标签。** 上面那 187 ms 的间隔会让它先折后展;同一次认领里通知和真人输入并存时还要额外判断。 + +**按 kind 写一张开启名单(`user`、`steering`)。** kind 区分不了 Step 1 的 steering 与 Turn 中途的 steering,真人往通知唤醒的 Turn 里插话时会错误地折叠前一个交互段。 + +**发布到 Turn 级 Location data。** 同一 Turn 的同一个 key 只允许一个 Context 发布,而开启输入来自多个消息 Context;改成按 Turn 汇总就得在新的 Definition 里重复 inbox 认领分类,并且每次都要读取所有 Turn。 + +**节点声明业务角色(`input`、`notice`)。** 这会把业务语义放进分组机制;分组树位置只描述结构,业务 Definition 自行决定位置。 + +**修正非自身输入折叠时的滚动补白。** 通知本来就不应触发折叠,修补白不能消除这次不必要的折叠;它仍是另一个标签页里真人输入的残留问题。 + +**这次就迁移两份 kind 名单。** 改动面明显超出这次缺陷;类型先预留位置,迁移另做。 + +## Consequences + +- 通知唤醒的 Turn 不再产生折叠、补白和空白;分组机制读取节点声明的位置,而不是点名 kind。 +- 「下次发送时收起」下,一长串通知唤醒的 Turn(抽样会话中 Goal 模式最长 31 轮)会一直展开,直到真人再次输入。 +- 已加载窗口里没有开启 Turn 时,所有已加载 Turn 都属于最新交互段。 +- 另一个标签页里的真人输入在它的 `user/message` 到达时才折叠,比原来在 `turn/start` 时晚约 200 ms;这类折叠仍使用通用补白,本次没有修改。 + +## Testing + +- `packages/client/ui-chat/tests/group-tree-segments.client.spec.ts`:位置顺序、切分、计数回退与空窗口。 +- `packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts`:最新交互段的成员与通知范围。 +- `packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts`:真实 assembler 下的开轮、中途 steering、Step 1 前的间隔、通知唤醒与空闲 steer。 +- `apps/web/tests/notice-wake-tail.e2e.ts`:长 Turn 之后由通知唤醒,在 Step 1 前的间隔和通知回复之后,上一轮都保持展开,跟尾读者看到通知与回复。 diff --git a/apps/web/tests/notice-wake-tail.e2e.ts b/apps/web/tests/notice-wake-tail.e2e.ts new file mode 100644 index 0000000000..884adcb9d3 --- /dev/null +++ b/apps/web/tests/notice-wake-tail.e2e.ts @@ -0,0 +1,218 @@ +/** + * A Turn this Client did not submit (a subagent-settled notice) wakes while the + * reader follows the tail of a long Turn under "On next message" folding. The + * notice Turn belongs to the long Turn's exchange, so the long Turn stays + * expanded through the host's pre-Step gap and after the notice arrives, and a + * reader who was following sees the notice and its reply at the tail. + */ +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { ToolCallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SubagentSettledMessageSource } from '@deepseek-ai/dsh-subagent' +import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage } from './support.ts' + +/** Settled Turns before the long one, so every work-details mode overflows the viewport. */ +const HISTORY_TURNS = 3 +const LONG_TURN = HISTORY_TURNS + 1 +const NOTICE_TURN = HISTORY_TURNS + 2 +/** Tool Steps in the finished Turn; their folded height must exceed the notice Turn's height. */ +const TOOL_STEPS = 12 +const OUTPUT_LINES = 40 +/** The recorded host gap between the waking claim and Step 1, spent assembling the system prompt. */ +const PRE_STEP_GAP_MS = 187 +const REPLY = 'NOTICE_WAKE_REPLY' + +interface Tail { + /** Height of the room reserved below the flow. */ + readonly spacer: number + readonly noticeVisible: boolean + readonly replyVisible: boolean + /** Whether the long Turn's process disclosure reports itself expanded. */ + readonly longTurnOpen: boolean +} + +function readTail(page: Page): Promise { + return page.evaluate(([reply, longTurn]) => { + const host = document.querySelector('[data-conversation-scroll]') + if (host === null) throw new Error('conversation scrollport is absent') + const viewTop = host.getBoundingClientRect().top + const viewBottom = host.querySelector('[data-composer-seat]')?.getBoundingClientRect().top + ?? host.getBoundingClientRect().bottom + const inView = (element: Element): boolean => { + const rect = element.getBoundingClientRect() + return rect.height > 0.5 && rect.bottom > viewTop && rect.top < viewBottom + } + const spacer = host.querySelector('[data-chat-turn-spacer]') + return { + spacer: spacer === null ? 0 : Number.parseFloat(spacer.style.height) || 0, + noticeVisible: [...host.querySelectorAll('[data-turn-trigger]')].some(inView), + replyVisible: [...host.querySelectorAll('[data-chat-flow-kind="assistant-step"]')] + .some(row => (row.textContent ?? '').includes(reply) && inView(row)), + longTurnOpen: host.querySelector(`[data-turn-process="${longTurn}"]`)?.getAttribute('aria-expanded') === 'true', + } + }, [REPLY, String(LONG_TURN)] as const) +} + +describe('web e2e: notice Turn after a long Detailed Turn', () => { + let browser: Browser + + beforeAll(async () => { + browser = await chromium.launch({ headless: true }) + }, 60_000) + + afterAll(async () => { + await browser?.close() + }) + + it.each(['detailed', 'standard'] as const)('keeps the new tail visible in %s work details', async (view) => { + const scaffold: WebScaffold = await launchWebScaffold({}) + const page = await newEnglishPage(browser) + const tripwire = watchConsole(page) + try { + await page.setViewportSize({ width: 1280, height: 800 }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + await scaffold.ctx.settings.update('ui-chat', { transcriptView: view, collapseTiming: 'next-input' }) + const agent = scaffold.ctx.agents.list().at(-1) + if (agent === undefined) throw new Error('connected workspace created no Agent') + const session = agent.session + const setRunning = (running: boolean): void => { scaffold.ctx.emit('api-session/status', agent.id, running) } + + for (let turn = 1; turn <= HISTORY_TURNS; turn += 1) { + session.append('turn/start', { turn }) + session.append('step/start', { turn, step: 1 }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: `Earlier question ${String(turn)}.` }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('assistant/message', { + stream: [], + turn, + step: 1, + message: createAssistantMessage({ + content: [{ type: 'text', text: `Earlier answer ${String(turn)}. ${'This settled reply wraps across several lines of prose. '.repeat(24)}` }], + source: { provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn, step: 1 }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + } + + // A long running Turn streams Step by Step while the reader follows the tail. + setRunning(true) + session.append('turn/start', { turn: LONG_TURN }) + session.append('step/start', { turn: LONG_TURN, step: 1 }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Run the long diagnostic.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + for (let step = 1; step <= TOOL_STEPS; step += 1) { + const callId = ToolCallId(`notice-wake-${String(step)}`) + const args = JSON.stringify({ command: `diagnose ${String(step)}`, description: `Diagnostic ${String(step)}` }) + session.append('assistant/message', { + stream: [], + turn: LONG_TURN, + step, + message: createAssistantMessage({ + content: [ + { type: 'reasoning', text: `Checking diagnostic ${String(step)}.` }, + { type: 'tool-call', id: callId, name: 'bash', arguments: args }, + ], + source: { provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + const call = session.append('tool/call', { turn: LONG_TURN, step, callId, name: 'bash', arguments: args }) + session.append('tool/result', { + turn: LONG_TURN, + step, + message: createToolResultMessage({ + callId, + content: [{ + type: 'text', + text: Array.from({ length: OUTPUT_LINES }, (_, line) => `diagnostic ${String(step)} line ${String(line + 1)}`).join('\n'), + }], + isError: false, + }), + }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] }) + session.append('step/end', { turn: LONG_TURN, step }) + session.append('step/start', { turn: LONG_TURN, step: step + 1 }) + await scaffold.ctx.sessions.flush(session) + await page.locator(`[data-chat-node-key$="${callId}"]`).waitFor({ state: 'attached', timeout: 15_000 }) + } + session.append('assistant/message', { + stream: [], + turn: LONG_TURN, + step: TOOL_STEPS + 1, + message: createAssistantMessage({ + content: [{ type: 'text', text: 'All diagnostics finished.' }], + source: { provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: LONG_TURN, step: TOOL_STEPS + 1 }) + await scaffold.ctx.sessions.flush(session) + await page.getByText('All diagnostics finished.').waitFor({ timeout: 30_000 }) + await page.evaluate(() => { + const host = document.querySelector('[data-conversation-scroll]') + if (host !== null) host.scrollTop = host.scrollHeight + }) + await page.locator('[data-chat-following-tail]').waitFor({ timeout: 10_000 }) + + // The Turn ends; 15 ms later (the recorded production gap) a settled subagent wakes the next Turn. + session.append('turn/end', { turn: LONG_TURN, reason: { kind: 'completed' } }) + await scaffold.ctx.sessions.flush(session) + setRunning(false) + await page.waitForTimeout(15) + const source: SubagentSettledMessageSource = { + kind: 'subagent-settled', + form: 'notice', + summary: 'Background subagent finished.', + senderSessionId: SessionId('notice-wake-child'), + } + const notice = createUserMessage({ + content: [{ type: 'text', text: 'Background subagent notice-wake-child finished.' }], + source, + }) + session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [notice] }) + session.append('turn/start', { turn: NOTICE_TURN }) + session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }) + setRunning(true) + await scaffold.ctx.sessions.flush(session) + // The browser has applied the notice Turn's start before the gap is held and sampled. + await page.locator(`[data-chat-turn="${String(NOTICE_TURN)}"]`).first().waitFor({ state: 'attached', timeout: 15_000 }) + await page.waitForTimeout(PRE_STEP_GAP_MS) + const gap = await readTail(page) + session.append('step/start', { turn: NOTICE_TURN, step: 1 }) + session.append('user/message', notice, { surfaceOp: 'append' }) + await scaffold.ctx.sessions.flush(session) + await page.locator('[data-turn-trigger]').first().waitFor({ state: 'attached', timeout: 15_000 }) + session.append('assistant/message', { + stream: [], + turn: NOTICE_TURN, + step: 1, + message: createAssistantMessage({ + content: [{ type: 'text', text: `${REPLY} the subagent result is acknowledged.` }], + source: { provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + await scaffold.ctx.sessions.flush(session) + + expect(gap).toMatchObject({ longTurnOpen: true, spacer: 0 }) + const settled = { noticeVisible: true, replyVisible: true, longTurnOpen: true, spacer: 0 } + await expect.poll(() => readTail(page), { timeout: 15_000 }).toMatchObject(settled) + // The settled tail survives later frames instead of passing through on the way to a fold. + await page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) }) + })) + expect(await readTail(page)).toMatchObject(settled) + expect(tripwire.warnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + } finally { + await page.context().close() + await scaffold.close() + } + }, 180_000) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 87b75c82b2..98232df721 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -23,6 +23,7 @@ // cannot see both sides of the cordis Context merges). "exclude": [ "tests/collapse-timing.e2e.ts", + "tests/notice-wake-tail.e2e.ts", "tests/chat-flow-layout.e2e.ts", "tests/layout-bottom.e2e.ts", "tests/shortcuts-desktop.e2e.ts", diff --git a/docs/glossary.i18n.yaml b/docs/glossary.i18n.yaml index e2e49e31f5..532c84896c 100644 --- a/docs/glossary.i18n.yaml +++ b/docs/glossary.i18n.yaml @@ -18,8 +18,8 @@ en: 38a55fc61b359ace zh: 567cae201b3a1bdf /glossary/loop-hierarchy: - en: 343a7cb31b8191c2 - zh: 6831415a7fe1726a + en: 6bf1ad7dd53f79cc + zh: 6231e68c769fdefa /glossary/ralph: en: 5059f8a377980b9d zh: 39bf11c10e5c0a84 diff --git a/docs/glossary.md b/docs/glossary.md index 1a9ac39a73..e30e4e2122 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -37,6 +37,7 @@ Domain vocabulary for DeepSeek Harness uses one canonical term per concept. Term - **turn** — one drain of admitted input in a session, ending after the model and its tools stop or a terminal policy intervenes. - **step** — one model request plus the tool executions caused by its response; a turn contains zero or more steps. - **round** — an outer policy iteration containing a turn, such as a [goal round](#goal-round) or one fresh-agent Ralph attempt. Round counters belong to that policy and do not count every turn in a session. +- **exchange** — consecutive [turns](#turn) from a turn whose first [step](#step) admits human input until the next such turn; turns woken by notices, such as settled subagents, goal rounds, or schedules, stay in the preceding exchange. Chat's next-message folding keeps the latest exchange expanded. ## Ralph diff --git a/docs/glossary.zh.md b/docs/glossary.zh.md index cd41876d41..68f5e4bf8c 100644 --- a/docs/glossary.zh.md +++ b/docs/glossary.zh.md @@ -37,6 +37,7 @@ DeepSeek Harness 的领域词汇为每个概念规定一个规范术语。各术 - **轮次**:会话中一次对已接纳输入的排空过程,在模型及其工具停止工作或终止策略介入后结束。 - **步骤**:一次模型请求,以及由模型响应引发的工具执行;一个轮次包含零个或多个步骤。 - **Round**:承载一个轮次的外层策略迭代,例如一个 [Goal Round](#goal-round) 或一次使用全新 agent 的 Ralph 尝试。Round 计数器归该策略所有,并不统计会话中的每个轮次。 +- **交互段**:连续的[轮次](#turn),从首个[步骤](#step)接纳真人输入的轮次起,到下一个这样的轮次为止;由通知(子 agent 完成、Goal Round、定时任务等)唤醒的轮次归入前面的交互段。Chat 的「下次发送时收起」保持最新交互段展开。 ## Ralph diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index cf7009ff7f..5971f88600 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -127,6 +127,7 @@ | event log | 事件日志 | | | | | event stream | 事件流 | | | | | event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 | +| exchange | 交互段 | | 回合、交换 | Chat 中从真人输入开启的轮次起、到下一个这样的轮次为止的连续轮次;见术语表 | | Executive summary | 摘要 | | | 事故复盘标题用语 | | executor | 执行器 | | | | | expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 | diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 3077ce4752..62f04ab2b8 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -33,8 +33,8 @@ en: 4cd7c81d70d2f365 zh: 18eca6d16e44b6c2 /deepseek-ai-dsh-client-ui-chat/grouped-rendering: - en: 106438891c5175ce - zh: 34dd30092f34f49b + en: bc8d1d786c9affea + zh: 3139f0228527a174 /deepseek-ai-dsh-client-ui-chat/scroll-ownership: en: 3f4e6c161b4583bb zh: e4265bb2bce8eb1c diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index b80ec590d6..697efe25cf 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -103,7 +103,7 @@ Chat registers its process Group Definition through `uiConversation.groups`. Rea `groupPart` selects reasoning or response in the Assistant renderer without copying Node payloads. A Tool node owns its preparing, dispatched, and result stages under one callId. Each part has a distinct DOM anchor for reading-position restoration; Turn navigation addresses the original Node key and lands on its first visible part. Group sources, member parents, and keys survive display-mode changes and newly loaded prefixes that extend an intact group. The source Node Store remains the only Node-data owner, and a replaced Builder rebinds keyed subscriptions without remounting seats. Mode changes retain size observers and reuse the Turn-state selector. -The Chat Builder publishes `bottomSource` per Node: true for visible Nodes belonging to the last Turn in the loaded timeline, not just the final row. Advancing the bottom Turn notifies only Nodes in the old and new bottom Turns. Groups read their first member's position. Pending-input deduplication and collapse decisions remain in the Chat view, outside the Builder. +The Chat Builder publishes `bottomSource` per Node: true for visible Nodes belonging to the latest loaded exchange, not just the final row. Nodes declare their group-tree position through `groupTree`; a Turn holding an `exchange` Node opens an exchange, and the exchange lasts until the next opening Turn. Moving the exchange start notifies only Nodes in Turns that entered or left it. Groups read their first member's position. Pending-input deduplication and collapse decisions remain in the Chat view, outside the Builder. Live tool deltas share reasoning's frame-batched publication; durable calls and results publish immediately. Native and nested results retain event presentation metadata, so historical file and command labels use the operation’s location. Repeated deltas retain the Tool node when observed argument answers, the anchor, location, and visibility are unchanged. The Definition supplies the same lazy argument reader to tool rows and group detail, without tool-specific registration. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index c07cd9c21c..fd0bec6b81 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -103,7 +103,7 @@ Chat 通过 `uiConversation.groups` 注册过程 Group Definition。React 通过 `groupPart` 在 Assistant 渲染器中选择推理或回复,不复制 Node 载荷。同一个 callId 的准备、派发与结果阶段由 Tool 节点自己拥有。每个部分有独立的 DOM 锚点用于恢复阅读位置;轮次导航使用原 Node key,落到它的第一个可见部分。展示模式切换以及为完整旧组补入更早成员时,保留组来源、成员父级及 key。原 Node Store 仍是唯一节点数据所有者,替换 Builder 时重新绑定按键订阅,不重挂载容器。模式变化保留尺寸观察器,并复用整轮状态选择器。 -Chat Builder 按节点发布 `bottomSource`:节点可见且属于已加载时间线的最后一轮时为 true,而不只是列表最后一行。底部轮次推进时,只有旧、新底部轮次的节点收到位置变化通知。Group 读取其首个成员的位置。待发送消息去重与收起判断仍由 Chat 视图处理,不进入 Builder。 +Chat Builder 按节点发布 `bottomSource`:节点可见且属于已加载的最新交互段时为 true,而不只是列表最后一行。节点通过 `groupTree` 声明自己在分组树中的位置;含 `exchange` 节点的轮次开启一个交互段,交互段持续到下一个开启轮次。交互段起点移动时,只有进入或离开该交互段的轮次中的节点收到通知。Group 读取其首个成员的位置。待发送消息去重与收起判断仍由 Chat 视图处理,不进入 Builder。 实时工具 delta 与推理共用按帧合并的发布节奏;持久调用和结果立即发布。原生与嵌套结果保留事件中的展示元数据,使历史文件与命令标签使用执行时的位置。当已读取参数的答案、锚点、位置及可见性均未变化时,重复 delta 保持 Tool 节点不变。Definition 向工具行和分组详情提供同一个懒计算参数读器,无需按工具注册。 diff --git a/packages/client/ui-chat/src/client/contract/chat-nodes.ts b/packages/client/ui-chat/src/client/contract/chat-nodes.ts index cade646b1c..c1cb6fad37 100644 --- a/packages/client/ui-chat/src/client/contract/chat-nodes.ts +++ b/packages/client/ui-chat/src/client/contract/chat-nodes.ts @@ -3,6 +3,7 @@ import type { ConversationLocation, ConversationViewNode, ModelRetryNode, RunningToolCall, ToolCallBlock, } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatGroupTree } from './group-tree.ts' /** Final Chat render unit produced by a Chat business Definition. */ export interface ChatConversationViewNode extends ConversationViewNode { @@ -10,6 +11,13 @@ export interface ChatConversationViewNode extends ConversationViewNode { readonly anchorSeq: number readonly location: ConversationLocation readonly visibility: 'visible' | 'hidden' + /** + * Group-tree position, visible or hidden. An `exchange` Node's Turn and every + * later Turn before the next opening Turn form one exchange; the latest + * loaded exchange starts at the last loaded opening Turn, or at the first + * loaded Turn when none is loaded. + */ + readonly groupTree?: ChatGroupTree | undefined } /** Merge-extensible payload registry keyed by final Chat renderer kind. */ diff --git a/packages/client/ui-chat/src/client/contract/group-tree.ts b/packages/client/ui-chat/src/client/contract/group-tree.ts new file mode 100644 index 0000000000..efe1dabe3a --- /dev/null +++ b/packages/client/ui-chat/src/client/contract/group-tree.ts @@ -0,0 +1,27 @@ +import type { ChatConversationViewNode } from './chat-nodes.ts' + +/** + * Chat group-tree positions from the outermost grouping inward; a Node at a + * position also stands outside every inner grouping. + * - `exchange`: the Node's Turn opens an exchange, the run of Turns that lasts + * until the next opening Turn. + * - `turn`: the Node stays in its Turn outside the Turn's process disclosure. + * - `process`: the Node stays in the process disclosure outside step runs. + * + * Only `exchange` is read today; process disclosure and step runs still select + * independent Nodes by kind (`TURN_PROCESS_INDEPENDENT_KINDS`, the process-group + * `INDEPENDENT` set), so no Definition publishes `turn` or `process` yet. + */ +export type ChatGroupTree = 'exchange' | 'turn' | 'process' + +const DEPTH: Readonly> = { exchange: 0, turn: 1, process: 2 } + +/** + * Test whether a Node stands at a group-tree position or outside it. + * @param node - Chat Node. + * @param position - group-tree position to compare against. + * @returns whether the Node's position is `position` or an outer one. + */ +export function isAtOrOutside(node: Pick, position: ChatGroupTree): boolean { + return node.groupTree !== undefined && DEPTH[node.groupTree] <= DEPTH[position] +} diff --git a/packages/client/ui-chat/src/client/contract/snapshot.ts b/packages/client/ui-chat/src/client/contract/snapshot.ts index b337c929bf..7e5f5038d3 100644 --- a/packages/client/ui-chat/src/client/contract/snapshot.ts +++ b/packages/client/ui-chat/src/client/contract/snapshot.ts @@ -35,7 +35,10 @@ export interface ChatNodeStore { get(key: string): ChatConversationViewNode | undefined /** @param key - stable Conversation Context key. @returns its identity-stable observable source. */ source(key: string): ChatNodeSource - /** @param key - Chat Node identity. @returns whether the visible Node belongs to the last Turn in the loaded timeline. */ + /** + * @param key - Chat Node identity. + * @returns whether the visible Node belongs to the latest loaded exchange (see `ChatConversationViewNode.groupTree`). + */ bottomSource(key: string): ObservableSnapshot /** * Observe one Turn's data for a single Node kind, including hidden Nodes, in anchor order. diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml index 5a6a4824ad..64ed556c17 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml @@ -24,8 +24,8 @@ en: 91b35cbebe8c2a1d zh: a4fee6d7102bc874 /chat-conversation-node-rules/whole-turn-folding: - en: 6feeb1527191633f - zh: 401656a7b9a4a4ed + en: 8e0c0b62da18a24f + zh: f740809d0b7ff2f5 /chat-conversation-node-rules/display-modes: en: ada5a60d2c46f6a8 zh: f3eaeb5cb4d9b3de @@ -33,8 +33,8 @@ en: b25bf00ce3a6a119 zh: 0ab47a4014ea5e1e /chat-conversation-node-rules/display-modes/collapse-timing: - en: d87667e4d1729e51 - zh: 014a14ebb16ac798 + en: 03d744dd1c92bdd3 + zh: 19804eaf3234632a /chat-conversation-node-rules/display-modes/group-title-rules: en: 2392290662bc69e8 zh: 8872d651c07a8f4c diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.md b/packages/client/ui-chat/src/client/conversation-nodes/README.md index 02bb1f08d4..a15f07a6a7 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.md @@ -70,7 +70,7 @@ If steering, a User message, or a trigger notice follows process output, the Tur Whole-Turn folding controls the loaded process range, independently of secondary groups, in Compact, Standard, and Detailed. Verbose keeps that range visible and retains the duration/status header without a collapse action. A recorded Turn end makes that range eligible even when paging has not loaded the Turn start. -The rules below use the default completion-time folding. The [collapse-timing preference](#collapse-timing) can defer the latest Turn's historical presentation without changing whole-Turn eligibility. +The rules below use the default completion-time folding. The [collapse-timing preference](#collapse-timing) can defer the latest exchange's historical presentation without changing whole-Turn eligibility. The Turn control follows all its opening inputs, including human steering and non-human trigger notices, while waiting for the first Assistant output and after that output arrives. Consecutive inputs before the first process evidence are opening inputs, anchored by the last one. Later inputs retain their positions: even when paging has not loaded their inbox insertions and they temporarily appear as ordinary User messages, preceding process content must not move after them. @@ -138,7 +138,7 @@ Reading a default does not automatically save configuration. When the user chang Settings → General → Collapse work details appears directly below Work details and is independent of the display mode. On completion is the default and retains immediate folding and tail positioning. On next message enables delayed folding and its coordinated transitions. The choice is saved as `ui-chat.collapseTiming` through the same Host profile settings as Work details and is restored after reload or reopening the app. Profiles without a saved value use On completion without writing that default. It does not write browser storage or Session data. -With On next message, the latest Turn keeps its running presentation after completion: Compact and Standard retain their process headers, and Detailed retains its flat process rows. A pending ordinary transcript submission or a newer Turn ends this exemption without clearing the reader's manual whole-Turn choice. Inner disclosures reset only when their outer process actually hides. Queued input and steering alone do not end it. The latest Turn of a reopened Session also receives the exemption; manual whole-Turn choices still take precedence. Switching back to On completion ends the exemption immediately. +With On next message, the latest exchange keeps its running presentation after completion: Compact and Standard retain their process headers, and Detailed retains its flat process rows. An exchange starts with the Turn whose first Step admits human input and includes every later Turn until the next such Turn, so a Turn woken by a notice (a settled subagent, goal round, schedule, or job) leaves the preceding Turns of its exchange expanded. A pending ordinary transcript submission or a Turn that opens a newer exchange ends this exemption without clearing the reader's manual whole-Turn choice; a bare Turn start does not, because the host claims the waking input before it logs Step 1. Inner disclosures reset only when their outer process actually hides. Queued input and steering alone do not end it. The latest exchange of a reopened Session also receives the exemption, starting at the first loaded Turn when the loaded window holds no opening Turn; manual whole-Turn choices still take precedence. Switching back to On completion ends the exemption immediately. When a new message triggers a fold, process details close and the transcript follows the new message in one upward movement, without a second scroll after the fold. Off-screen steps do not add bottom space; only the space needed to preserve the visible movement remains when the retained answer and new message cannot fill it. Content added during the fold updates its remaining movement. Reopening every closing row cancels that movement and releases obsolete bottom space. A reading gesture interrupts automatic following; finishing the fold does not resume it. diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md index 600b02c266..089a0b886a 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md @@ -70,7 +70,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" 简洁、标准、详细模式的整轮折叠控制已加载的过程范围,独立于二级组。完全展开模式保持该范围可见,保留时长或状态抬头,但不支持收起。已经记录轮次结束时,即使向前分页尚未加载到轮次起点,也可以对该范围折叠。 -以下规则采用默认的完成时收起。[收起时机设置](#collapse-timing) 可以推迟最新轮次恢复历史呈现的时间,不改变整轮折叠资格。 +以下规则采用默认的完成时收起。[收起时机设置](#collapse-timing) 可以推迟最新交互段恢复历史呈现的时间,不改变整轮折叠资格。 整轮控件位于该轮所有起始输入之后,包括人工 steering 和非人工触发通知;等待首个 Assistant 输出及输出到达后均保持这一顺序。首个过程证据之前的连续输入都属于开场,以最后一条为准;过程证据之后的输入留在原位,即使分页尚未加载其入队记录、消息暂时归为普通 User,也不能把前面的过程移到它后面。 @@ -138,7 +138,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" 设置 → 通用设置 → 过程收起时机位于工作步骤展示正下方,与展示模式独立。「完成时收起」是默认值,保留立即折叠和即时跟尾定位;「下次发送时收起」启用延后折叠及其协调过渡。选择以 `ui-chat.collapseTiming` 保存到与工作步骤展示相同的 Host profile 设置,刷新或重新打开应用后会恢复。未保存此值的 profile 使用「完成时收起」,不会自动写入默认值。此偏好不写入浏览器存储或 Session 数据。 -选择「下次发送时收起」后,最新轮次完成时保留运行中的呈现:简洁和标准保留过程组头,详细保留平铺过程行。普通 transcript 输入进入待发送状态,或出现更新轮次时,取消这项例外,但保留用户对整轮的手动开合选择。只有外层过程实际隐藏时,才重置内部开合。排队输入和单独的 steering 不会取消例外。重新打开 Session 时,其最新轮次也享有例外;整轮的手动开合选择仍优先。切回「完成时收起」会立即取消例外。 +选择「下次发送时收起」后,最新交互段完成时保留运行中的呈现:简洁和标准保留过程组头,详细保留平铺过程行。交互段从首个 Step 接收真人输入的轮次开始,包含之后的所有轮次,直到下一个这样的轮次,因此由通知(子 agent 完成、Goal Round、定时任务或后台作业)唤醒的轮次不会收起同一交互段里更早的轮次。普通 transcript 输入进入待发送状态,或出现开启更新交互段的轮次时,取消这项例外,但保留用户对整轮的手动开合选择;仅出现新轮次的开始不会取消,因为 Host 先认领唤醒输入,再记录 Step 1。只有外层过程实际隐藏时,才重置内部开合。排队输入和单独的 steering 不会取消例外。重新打开 Session 时,其最新交互段也享有例外,已加载窗口中没有开启轮次时从第一个已加载轮次算起;整轮的手动开合选择仍优先。切回「完成时收起」会立即取消例外。 新消息触发折叠时,过程收起与跟随新消息合为一次向上运动,折叠结束后不再追加滚动。屏幕外的步骤不增加底部补白;只有保留的正文和新消息无法填满可见内容移动所需空间时,才保留必要差额。折叠期间新增的内容会更新剩余运动;重新展开全部正在收起的行时,取消该运动并释放失效的底部补白。阅读手势会中断自动跟随,折叠结束不会重新接管。 diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index 0423e1ff20..dad0b6e51e 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -12,7 +12,9 @@ import type { ChatTurnNavigationIndex, ChatTurnProcessPresentation, LegacyConversationSlice, TurnNavigationItem, } from '../contract/snapshot.ts' import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts' +import { locationCoordinates } from './common.ts' import { sessionRecallLabels, skillInvocationName } from './event-projection.ts' +import { GroupTreeSegments } from './group-tree-segments.ts' import { sameTurnNavigationItem, turnNavigationItem } from './turn-navigation.ts' import { ChatTurnProcessProjector } from './turn-process-presentation.ts' @@ -108,7 +110,8 @@ class MutableChatNodeStore implements ChatNodeStore { private readonly sources = new Map>() private readonly processSources = new Map>() private readonly bottomSources = new Map>() - private bottomTurn: number | undefined + /** Bottom membership: the latest loaded exchange. */ + private readonly exchanges = new GroupTreeSegments('exchange') private readonly dirtyKeys = new Set() private readonly dirtyBottomKeys = new Set() private readonly dirtyProcessKeys = new Set() @@ -131,19 +134,21 @@ class MutableChatNodeStore implements ChatNodeStore { bottomSource(key: string): ObservableSnapshot { return cachedSource(this.bottomSources, key, () => new MutableChatSource(() => { const node = this.get(key) as ChatNode | undefined - return this.bottomTurn !== undefined && node !== undefined && isVisibleChatNode(node) - && locationCoordinates(node.location).turn === this.bottomTurn + return node !== undefined && isVisibleChatNode(node) + && this.exchanges.inLatest(locationCoordinates(node.location).turn) }, `[ui-chat] node bottom source ${key}`)) } - setBottomTurn(turn: number | undefined, locations: ChatLocationNodeIndex): void { - if (turn === this.bottomTurn) return - for (const changed of [this.bottomTurn, turn]) { - if (changed !== undefined) { - for (const key of locations.getTurn(changed)) this.dirtyBottomKeys.add(key) - } + /** + * Settle the latest exchange for the loaded Turns and mark the Nodes of + * Turns that entered or left it. + * @param turnOrder - loaded Turns in ascending timeline order. + * @param locations - current Chat Location index. + */ + settleBottom(turnOrder: readonly number[], locations: ChatLocationNodeIndex): void { + for (const turn of this.exchanges.settle(turnOrder)) { + for (const key of locations.getTurn(turn)) this.dirtyBottomKeys.add(key) } - this.bottomTurn = turn } turnDataSource(turn: number, kind: Kind): ObservableSnapshot { @@ -196,6 +201,7 @@ class MutableChatNodeStore implements ChatNodeStore { this.byKey.set(node.key, node) if (previous.get(node.key) !== node) { this.updateTurnKind(previous.get(node.key), node) + this.exchanges.track(previous.get(node.key), node) this.dirtyKeys.add(node.key) this.dirtyProcessKeys.add(node.key) } @@ -203,6 +209,7 @@ class MutableChatNodeStore implements ChatNodeStore { } for (const key of previous.keys()) { this.updateTurnKind(previous.get(key), undefined) + this.exchanges.track(previous.get(key), undefined) this.dirtyKeys.add(key) this.dirtyProcessKeys.add(key) } @@ -215,6 +222,7 @@ class MutableChatNodeStore implements ChatNodeStore { for (const node of nodes) { if (this.byKey.get(node.key) === node) continue this.updateTurnKind(this.byKey.get(node.key), node) + this.exchanges.track(this.byKey.get(node.key), node) this.byKey.set(node.key, node) this.dirtyKeys.add(node.key) this.dirtyProcessKeys.add(node.key) @@ -408,12 +416,6 @@ function stepKey(turn: number, step: number): string { return `${turn}:${step}` } -function locationCoordinates(location: ConversationLocation): { turn?: number; step?: number } { - if (location.kind === 'step') return { turn: location.turn.turn, step: location.step.step } - if (location.kind === 'turn') return { turn: location.turn.turn } - return {} -} - function locationTurnStatus(location: ConversationLocation): string | undefined { return location.kind === 'turn' || location.kind === 'step' ? location.turn.status : undefined } @@ -1114,7 +1116,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder node.key) this.locations.rebuild(this.order, this.store) this.store.replaceProcesses(this.order, this.locations) - this.store.setBottomTurn(input.timeline.turnOrder.at(-1), this.locations) + this.store.settleBottom(input.timeline.turnOrder, this.locations) this.navigation.rebuild(input.timeline, this.locations, this.store) this.timeline = input.timeline this.latestGroupInput = { @@ -1165,7 +1167,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder( @@ -45,6 +57,7 @@ export function chatNode( options: { readonly location?: ConversationLocation readonly visibility?: 'visible' | 'hidden' + readonly groupTree?: ChatGroupTree | undefined } = {}, ): ChatNode { return { @@ -55,6 +68,7 @@ export function chatNode( anchorSeq, location: options.location ?? contextLocation(context), visibility: options.visibility ?? 'visible', + groupTree: options.groupTree, data, } } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/group-tree-segments.ts b/packages/client/ui-chat/src/client/conversation-nodes/group-tree-segments.ts new file mode 100644 index 0000000000..738b79ef29 --- /dev/null +++ b/packages/client/ui-chat/src/client/conversation-nodes/group-tree-segments.ts @@ -0,0 +1,91 @@ +import type { ChatConversationViewNode } from '../contract/chat-nodes.ts' +import { isAtOrOutside, type ChatGroupTree } from '../contract/group-tree.ts' +import { locationCoordinates } from './common.ts' + +const NO_TURNS: readonly number[] = [] + +/** + * Loaded Turns split into consecutive segments at the Turns holding a Node at + * one group-tree position, visible or hidden. The latest segment starts at the + * last such Turn, or at the first loaded Turn when none is loaded. Node changes + * update per-Turn counts without scanning the loaded Nodes; `settle` returns at + * once while the start holds and otherwise reads loaded Turns only from the + * earlier of the previous and new starts. + */ +export class GroupTreeSegments { + /** Opening Node count by owning Turn. */ + private readonly openings = new Map() + private latestOpening: number | undefined + private latestStart: number | undefined + + /** @param position - group-tree position whose Nodes open a segment. */ + constructor(private readonly position: ChatGroupTree) {} + + /** + * Account one Node change. + * @param previous - replaced Node; absent for an addition. + * @param next - replacing Node; absent for a removal. + */ + track(previous: ChatConversationViewNode | undefined, next: ChatConversationViewNode | undefined): void { + const before = this.openingTurn(previous) + const after = this.openingTurn(next) + if (before === after) return + if (before !== undefined && this.adjust(before, -1) === 0 && before === this.latestOpening) { + this.latestOpening = this.lastOpeningTurn() + } + if (after !== undefined) { + this.adjust(after, 1) + if (this.latestOpening === undefined || after > this.latestOpening) this.latestOpening = after + } + } + + /** + * Test latest-segment membership as of the last `settle`. + * @param turn - owning Turn, when resolved. + * @returns whether the Turn belongs to the latest segment. + */ + inLatest(turn: number | undefined): boolean { + return turn !== undefined && this.latestStart !== undefined && turn >= this.latestStart + } + + /** + * Move the latest segment start for the currently loaded Turns. + * @param turnOrder - loaded Turns in ascending timeline order. + * @returns loaded Turns that entered or left the latest segment, in ascending order. + */ + settle(turnOrder: readonly number[]): readonly number[] { + const previous = this.latestStart + const start = this.latestOpening ?? turnOrder[0] + if (start === previous) return NO_TURNS + this.latestStart = start + const bounded = previous !== undefined && start !== undefined + const low = bounded ? Math.min(previous, start) : Number.NEGATIVE_INFINITY + const high = bounded ? Math.max(previous, start) : Number.POSITIVE_INFINITY + const changed: number[] = [] + for (const turn of turnOrder.slice(turnOrder.findLastIndex(turn => turn < low) + 1)) { + if (turn >= high) break + changed.push(turn) + } + return changed + } + + private openingTurn(node: ChatConversationViewNode | undefined): number | undefined { + return node !== undefined && isAtOrOutside(node, this.position) ? locationCoordinates(node.location).turn : undefined + } + + private lastOpeningTurn(): number | undefined { + let last: number | undefined + for (const turn of this.openings.keys()) { + if (last === undefined || turn > last) last = turn + } + return last + } + + /** @returns the Turn's opening count after the change; zero removes the Turn. */ + private adjust(turn: number, delta: number): number { + const next = (this.openings.get(turn) ?? 0) + delta + if (next > 0) this.openings.set(turn, next) + else this.openings.delete(turn) + return next + } +} diff --git a/packages/client/ui-chat/src/client/conversation-nodes/message.ts b/packages/client/ui-chat/src/client/conversation-nodes/message.ts index ddfffb0cca..d5ef45f93c 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/message.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/message.ts @@ -4,7 +4,7 @@ import type { } from '@deepseek-ai/dsh-client-ui-conversation/client' import { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type { InboxState } from './inbox.ts' -import { chatNode } from './common.ts' +import { chatNode, contextLocation } from './common.ts' import { contextForm, contextProducer } from './event-projection.ts' interface ReferencedUserMessageNode extends UserMessageNode { @@ -115,7 +115,18 @@ export const messageDefinition: ConversationNodeDefinition = { const waking = context.state.kind === 'context' && context.start?.event.type === 'user/message' && context.state.waking === true - return chatNode(context, waking ? 'turn-trigger' : context.state.kind, context.state.seq, context.state) + // Human input claimed for Step 1 opens an exchange; Notice-woken Turns and + // later steering stay in the current exchange, so they never fold earlier Turns. + const location = contextLocation(context) + const opensExchange = (context.state.kind === 'user' || context.state.kind === 'steering') + && location.kind === 'step' && location.step.step === 1 + return chatNode( + context, + waking ? 'turn-trigger' : context.state.kind, + context.state.seq, + context.state, + { location, groupTree: opensExchange ? 'exchange' : undefined }, + ) }, } diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts index 09edabb684..275d76b96b 100644 --- a/packages/client/ui-chat/src/client/index.ts +++ b/packages/client/ui-chat/src/client/index.ts @@ -27,6 +27,7 @@ export type { FinalAssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData, TurnProcessChatData, TurnTailChatData, } from './contract/chat-nodes.ts' +export type { ChatGroupTree } from './contract/group-tree.ts' export type { ChatStoreState, ToolCallId, TurnProcessViewEntry } from './contract/store.ts' export type { TranscriptViewRowInjected, TranscriptViewRowProps } from './settings/TranscriptViewRow.tsx' export type { TranscriptViewMode } from '../chat-settings.ts' diff --git a/packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts b/packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts index ef52f7a258..d72295e212 100644 --- a/packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts +++ b/packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts @@ -18,15 +18,20 @@ function node(index: number, owner = turn(index), text = ''): ChatConversationVi } } +/** A Node whose Turn opens an exchange. */ +function opening(index: number, owner = turn(index), text = ''): ChatConversationViewNode { + return { ...node(index, owner, text), groupTree: 'exchange' } +} + function timeline(turns: readonly TurnLocation[]): ConversationTimelineSnapshot { return { turnOrder: turns.map(value => value.turn), turns: new Map(turns.map(value => [value.turn, value])) } } describe('assembler-owned Chat bottom membership', () => { - it('notifies only old and new bottom keys among 4,000 observed historical Nodes', () => { + it('notifies only the Turns leaving or entering the latest exchange among 4,000 observed historical Nodes', () => { const builder = new ChatSnapshotBuilder() const turns = Array.from({ length: 4_000 }, (_, index) => turn(index + 1)) - const nodes = turns.map(value => node(value.turn, value)) + const nodes = turns.map(value => opening(value.turn, value)) const snapshot = builder.replace({ nodes, timeline: timeline(turns) }) builder.publish() const sources = nodes.map(value => snapshot.nodes.bottomSource(value.key)) @@ -36,7 +41,7 @@ describe('assembler-owned Chat bottom membership', () => { return listener }) const nextTurn = turn(4_001) - const nextNode = node(4_001, nextTurn) + const nextNode = opening(4_001, nextTurn) const nextSource = snapshot.nodes.bottomSource(nextNode.key) const nextChanged = vi.fn() nextSource.subscribe(nextChanged) @@ -55,17 +60,17 @@ describe('assembler-owned Chat bottom membership', () => { expect(snapshot.nodes.bottomSource(nextNode.key)).toBe(nextSource) nextChanged.mockClear() - builder.apply({ upserts: [node(4_001, nextTurn, 'updated')], timeline: timeline([...turns, nextTurn]) }) + builder.apply({ upserts: [opening(4_001, nextTurn, 'updated')], timeline: timeline([...turns, nextTurn]) }) builder.publish() expect(nextChanged).not.toHaveBeenCalled() expect(listeners.at(-1)).toHaveBeenCalledOnce() }) - it('advances on a Turn boundary before its Node materializes and retains source identity across replacement', () => { + it('keeps a bare Turn boundary and continuation Turns in the latest exchange until an opening Node arrives', () => { const builder = new ChatSnapshotBuilder() const first = turn(1) const second = turn(2) - const initial = builder.replace({ nodes: [node(1, first)], timeline: timeline([first]) }) + const initial = builder.replace({ nodes: [opening(1, first)], timeline: timeline([first]) }) builder.publish() const oldBottom = initial.nodes.bottomSource('user:1') const nextBottom = initial.nodes.bottomSource('user:2') @@ -73,26 +78,92 @@ describe('assembler-owned Chat bottom membership', () => { const unsubscribe = oldBottom.subscribe(changed) builder.apply({ upserts: [], timeline: timeline([first, second]) }) builder.publish() - expect(changed).toHaveBeenCalledOnce() - expect(oldBottom.getSnapshot()).toBe(false) - expect(nextBottom.getSnapshot()).toBe(false) + expect(changed).not.toHaveBeenCalled() + expect(oldBottom.getSnapshot()).toBe(true) + builder.apply({ upserts: [node(2, second)], timeline: timeline([first, second]) }) builder.publish() - expect(nextBottom.getSnapshot()).toBe(true) + expect(changed).not.toHaveBeenCalled() + expect([oldBottom.getSnapshot(), nextBottom.getSnapshot()]).toEqual([true, true]) + + builder.apply({ upserts: [opening(2, second)], timeline: timeline([first, second]) }) + builder.publish() + expect(changed).toHaveBeenCalledOnce() + expect([oldBottom.getSnapshot(), nextBottom.getSnapshot()]).toEqual([false, true]) unsubscribe() - const replaced = builder.replace({ nodes: [node(1, first)], timeline: timeline([first]) }) + + const replaced = builder.replace({ nodes: [opening(1, first)], timeline: timeline([first]) }) builder.publish() expect(replaced.nodes.bottomSource('user:1')).toBe(oldBottom) - expect(oldBottom.getSnapshot()).toBe(true) - expect(nextBottom.getSnapshot()).toBe(false) - expect(changed).toHaveBeenCalledOnce() + expect([oldBottom.getSnapshot(), nextBottom.getSnapshot()]).toEqual([true, false]) + + builder.replace({ nodes: [node(1, first), node(2, second)], timeline: timeline([first, second]) }) + builder.publish() + expect([oldBottom.getSnapshot(), nextBottom.getSnapshot()]).toEqual([true, true]) + }) + + it('starts the latest exchange at the first loaded Turn while no opening Node is loaded', () => { + const builder = new ChatSnapshotBuilder() + const turns = [turn(3), turn(4), turn(5)] + const initial = builder.replace({ nodes: turns.map(value => node(value.turn, value)), timeline: timeline(turns) }) + builder.publish() + const bottoms = ['user:3', 'user:4', 'user:5'].map(key => initial.nodes.bottomSource(key)) + expect(bottoms.map(source => source.getSnapshot())).toEqual([true, true, true]) + + const older = [turn(1), turn(2), ...turns] + const loaded = builder.replace({ nodes: older.map(value => node(value.turn, value)), timeline: timeline(older) }) + builder.publish() + expect(['user:1', 'user:2'].map(key => loaded.nodes.bottomSource(key).getSnapshot())).toEqual([true, true]) + + builder.replace({ nodes: [], timeline: timeline([]) }) + builder.publish() + expect(bottoms.map(source => source.getSnapshot())).toEqual([false, false, false]) + }) + + it('follows the latest remaining opening Turn when opening Nodes leave, move, or repeat in one Turn', () => { + const builder = new ChatSnapshotBuilder() + const turns = [turn(1), turn(2), turn(3), turn(4)] + const second: ChatConversationViewNode = { ...opening(22, turns[1]), key: 'user:2b' } + const nodes = [opening(1, turns[0]), opening(2, turns[1]), second, node(3, turns[2]), opening(4, turns[3])] + const initial = builder.replace({ nodes, timeline: timeline(turns) }) + builder.publish() + const bottom = (key: string): boolean => initial.nodes.bottomSource(key).getSnapshot() + const membership = (): boolean[] => ['user:1', 'user:2', 'user:3', 'user:4'].map(bottom) + expect(membership()).toEqual([false, false, false, true]) + + const before = vi.fn() + const unchanged = vi.fn() + initial.nodes.bottomSource('user:2').subscribe(before) + initial.nodes.bottomSource('user:1').subscribe(unchanged) + builder.apply({ upserts: [node(4, turns[3])], timeline: initial.timeline }) + builder.publish() + expect(membership()).toEqual([false, true, true, true]) + expect(before).toHaveBeenCalledOnce() + expect(unchanged).not.toHaveBeenCalled() + + builder.apply({ upserts: [node(2, turns[1])], timeline: initial.timeline }) + builder.publish() + expect(membership()).toEqual([false, true, true, true]) + + builder.apply({ upserts: [{ ...second, location: { kind: 'session' } }], timeline: initial.timeline }) + builder.publish() + expect(membership()).toEqual([true, true, true, true]) + + builder.apply({ upserts: [{ ...opening(3, turns[2]), visibility: 'hidden' }], timeline: initial.timeline }) + builder.publish() + expect(membership()).toEqual([false, false, false, true]) + expect(bottom('user:2b')).toBe(false) + + builder.replace({ nodes: [opening(1, turns[0]), node(2, turns[1]), node(4, turns[3])], timeline: initial.timeline }) + builder.publish() + expect(membership()).toEqual([true, true, false, true]) }) it('updates same-key Turn moves without changing root order and excludes unplaced or hidden Nodes', () => { const builder = new ChatSnapshotBuilder() const first = turn(1) const second = turn(2) - const initial = builder.replace({ nodes: [node(1, first)], timeline: timeline([first, second]) }) + const initial = builder.replace({ nodes: [node(1, first), opening(2, second)], timeline: timeline([first, second]) }) builder.publish() const bottom = initial.nodes.bottomSource('user:1') expect(bottom.getSnapshot()).toBe(false) diff --git a/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts index 1a0ed20d21..ad4d4357a4 100644 --- a/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts +++ b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts @@ -19,6 +19,7 @@ import { isSubagentDelegationTool, sameTurnProcessSpec, TURN_PROCESS_INDEPENDENT_KINDS, type TurnProcessSpec, } from '../src/client/contract/turn-process.ts' +import { isAtOrOutside } from '../src/client/contract/group-tree.ts' const EMPTY: readonly never[] = [] @@ -106,7 +107,8 @@ class FixtureNodeStore implements ChatNodeStore { private readonly sources = new Map>() private readonly processSources = new Map>() private readonly bottomSources = new Map>() - bottomTurn: number | undefined + /** First Turn of the latest loaded exchange. */ + bottomStart: number | undefined private readonly dirtyKeys = new Set() private readonly dirtyProcessKeys = new Set() private list: readonly ChatConversationViewNode[] = EMPTY @@ -122,8 +124,8 @@ class FixtureNodeStore implements ChatNodeStore { bottomSource(key: string): ReturnType { return cachedSource(this.bottomSources, key, () => new FixtureSource(() => { const location = this.get(key)?.location - return this.bottomTurn !== undefined && (location?.kind === 'turn' || location?.kind === 'step') - && location.turn.turn === this.bottomTurn + return this.bottomStart !== undefined && (location?.kind === 'turn' || location?.kind === 'step') + && location.turn.turn >= this.bottomStart })) } @@ -270,6 +272,8 @@ function settledNode( ? { kind: 'session' as const } : { kind: 'turn' as const, turn }, visibility: 'visible' as const, + // A fixture user message inside a Turn stands for its opening human input. + groupTree: node.kind === 'user' && turn !== undefined ? 'exchange' as const : undefined, } switch (node.kind) { case 'assistant': @@ -527,7 +531,11 @@ export function chatSnapshotFixture(input: { && previous.legacy.turnEnds === legacy.turnEnds ? previous.timeline : { turnOrder: [...turns.keys()], turns } - store.bottomTurn = timeline.turnOrder.at(-1) + const openingTurns = store.values().flatMap((node) => { + const location = node.location + return isAtOrOutside(node, 'exchange') && (location.kind === 'turn' || location.kind === 'step') ? [location.turn.turn] : [] + }) + store.bottomStart = openingTurns.length === 0 ? timeline.turnOrder[0] : Math.max(...openingTurns) const derived = timeline.turnOrder .map(turn => turnNavigationItem(turn, locations, store)) .filter((item): item is TurnNavigationItem => item !== undefined) diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index 3823a0dbd5..3dc8c784db 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -659,6 +659,62 @@ describe('built-in conversation node Definitions', () => { } }) + it('opens exchanges only for human input claimed for Step 1', () => { + const human = textMessage('block-human', 'first task') + const steer = textMessage('block-steer', 'adjust course') + const notice = { ...textMessage('block-notice', 'background work finished'), source: { kind: 'schedule' } } + const idle = textMessage('block-idle', 'next task') + const bottomTurns = (current: ChatSnapshot): number[] => [...new Set(current.order.flatMap((key) => { + const location = current.nodes.get(key)?.location + return current.nodes.bottomSource(key).getSnapshot() && (location?.kind === 'turn' || location?.kind === 'step') + ? [location.turn.turn] + : [] + }))] + const inputs = (current: ChatSnapshot) => current.order + .map(key => current.nodes.get(key)) + .filter(candidate => candidate?.kind === 'user' || candidate?.kind === 'steering' || candidate?.kind === 'turn-trigger') + .map(candidate => [candidate?.kind, candidate?.groupTree ?? null]) + const value = assembler([ + at(1, 'agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [human] }), + at(2, 'turn/start', { turn: 1 }), + at(3, 'agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }), + at(4, 'step/start', { turn: 1, step: 1 }), + at(5, 'user/message', human, { surfaceOp: 'append' }), + at(6, 'step/end', { turn: 1, step: 1 }), + at(7, 'agent/inbox/spliced', { target: 'next-step', start: 0, inserted: [steer] }), + at(8, 'agent/inbox/spliced', { target: 'next-step', start: 0, removedCount: 1, inserted: [] }), + at(9, 'step/start', { turn: 1, step: 2 }), + at(10, 'user/message', steer, { surfaceOp: 'append' }), + at(11, 'assistant/message', { turn: 1, step: 2, message: assistantMessage('block-answer-1', 'done') }, { surfaceOp: 'append' }), + at(12, 'step/end', { turn: 1, step: 2 }), + at(13, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + // The host assembles the system prompt between the waking claim and Step 1. + at(14, 'agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [notice] }), + at(15, 'turn/start', { turn: 2 }), + at(16, 'agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }), + ]) + expect(inputs(snapshot(value))).toEqual([['user', 'exchange'], ['steering', null]]) + expect(bottomTurns(snapshot(value))).toEqual([1, 2]) + + value.append(at(17, 'step/start', { turn: 2, step: 1 })) + value.append(at(18, 'user/message', notice, { surfaceOp: 'append' })) + value.append(at(19, 'assistant/message', { turn: 2, step: 1, message: assistantMessage('block-answer-2', 'noted') }, { surfaceOp: 'append' })) + value.append(at(20, 'step/end', { turn: 2, step: 1 })) + value.append(at(21, 'turn/end', { turn: 2, reason: { kind: 'completed' } })) + value.flush() + expect(inputs(snapshot(value))).toEqual([['user', 'exchange'], ['steering', null], ['turn-trigger', null]]) + expect(bottomTurns(snapshot(value))).toEqual([1, 2]) + + value.append(at(22, 'agent/inbox/spliced', { target: 'next-step', start: 0, inserted: [idle] })) + value.append(at(23, 'turn/start', { turn: 3 })) + value.append(at(24, 'agent/inbox/spliced', { target: 'next-step', start: 0, removedCount: 1, inserted: [] })) + value.append(at(25, 'step/start', { turn: 3, step: 1 })) + value.append(at(26, 'user/message', idle, { surfaceOp: 'append' })) + value.flush() + expect(inputs(snapshot(value)).at(-1)).toEqual(['steering', 'exchange']) + expect(bottomTurns(snapshot(value))).toEqual([3]) + }) + it('replays pending splice chains and scopes steering to the current claim', () => { const first = textMessage('claim-first', 'first') const second = textMessage('claim-second', 'second') diff --git a/packages/client/ui-chat/tests/group-tree-segments.client.spec.ts b/packages/client/ui-chat/tests/group-tree-segments.client.spec.ts new file mode 100644 index 0000000000..377ca9c7d2 --- /dev/null +++ b/packages/client/ui-chat/tests/group-tree-segments.client.spec.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest' +import type { TurnLocation } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatConversationViewNode } from '../src/client/contract/chat-nodes.ts' +import { isAtOrOutside, type ChatGroupTree } from '../src/client/contract/group-tree.ts' +import { GroupTreeSegments } from '../src/client/conversation-nodes/group-tree-segments.ts' + +function turn(index: number): TurnLocation { + return { + turn: index, start: undefined, end: undefined, status: 'closed', steps: [], + data: { get: () => undefined, source: () => ({ getSnapshot: () => undefined, subscribe: () => () => {} }) }, + } +} + +function node(key: string, owner: number | undefined, groupTree?: ChatGroupTree): ChatConversationViewNode { + return { + key, id: key, kind: 'user', target: 'chat', anchorSeq: 0, visibility: 'hidden', groupTree, + location: owner === undefined ? { kind: 'session' } : { kind: 'turn', turn: turn(owner) }, + data: null, + } +} + +function members(segments: GroupTreeSegments, turns: readonly number[]): number[] { + return turns.filter(value => segments.inLatest(value)) +} + +describe('Chat group tree', () => { + it('orders positions from the outermost exchange inward', () => { + const at = (groupTree?: ChatGroupTree) => (['exchange', 'turn', 'process'] as const) + .map(position => isAtOrOutside({ groupTree }, position)) + expect(at('exchange')).toEqual([true, true, true]) + expect(at('turn')).toEqual([false, true, true]) + expect(at('process')).toEqual([false, false, true]) + expect(at(undefined)).toEqual([false, false, false]) + }) + + it('starts the latest segment at the last opening Turn and reports only the Turns that moved', () => { + const segments = new GroupTreeSegments('exchange') + const order = [1, 2, 3, 4, 5] + expect(segments.inLatest(1)).toBe(false) + expect(segments.settle(order)).toEqual(order) + expect(members(segments, order)).toEqual(order) + + segments.track(undefined, node('a', 2, 'exchange')) + expect(segments.settle(order)).toEqual([1]) + expect(members(segments, order)).toEqual([2, 3, 4, 5]) + expect(segments.settle(order)).toEqual([]) + + segments.track(undefined, node('b', 4, 'exchange')) + expect(segments.settle(order)).toEqual([2, 3]) + expect(members(segments, order)).toEqual([4, 5]) + expect(segments.inLatest(undefined)).toBe(false) + + segments.track(node('b', 4, 'exchange'), undefined) + expect(segments.settle(order)).toEqual([2, 3]) + expect(members(segments, order)).toEqual([2, 3, 4, 5]) + }) + + it('counts every opening Node of a Turn and falls back as openings leave', () => { + const segments = new GroupTreeSegments('exchange') + const order = [1, 2, 3] + const first = node('first', 1, 'exchange') + const twin = node('twin', 3, 'exchange') + const late = node('late', 3, 'exchange') + for (const value of [late, first, twin]) segments.track(undefined, value) + segments.settle(order) + expect(members(segments, order)).toEqual([3]) + + segments.track(late, node('late', 3)) + segments.settle(order) + expect(members(segments, order)).toEqual([3]) + + segments.track(first, undefined) + segments.settle(order) + expect(members(segments, order)).toEqual([3]) + + segments.track(twin, { ...twin, location: { kind: 'session' } }) + expect(segments.settle(order)).toEqual([1, 2]) + expect(members(segments, order)).toEqual(order) + }) + + it('falls back to the latest remaining opening Turn whatever order the openings arrived in', () => { + const segments = new GroupTreeSegments('exchange') + const order = [1, 2, 3, 4, 5] + const latest = node('latest', 5, 'exchange') + for (const value of [node('middle', 3, 'exchange'), node('earliest', 1, 'exchange'), latest]) { + segments.track(undefined, value) + } + segments.settle(order) + expect(members(segments, order)).toEqual([5]) + + segments.track(latest, undefined) + expect(segments.settle(order)).toEqual([3, 4]) + expect(members(segments, order)).toEqual([3, 4, 5]) + }) + + it('opens segments at Nodes of the configured position or an outer one', () => { + const segments = new GroupTreeSegments('turn') + const order = [1, 2, 3] + segments.track(undefined, node('process', 3, 'process')) + segments.track(undefined, node('turn', 2, 'turn')) + segments.track(undefined, node('exchange', 1, 'exchange')) + segments.track(node('none', 3), node('none', 3)) + segments.settle(order) + expect(members(segments, order)).toEqual([2, 3]) + }) + + it('clears the latest segment when no Turn is loaded', () => { + const segments = new GroupTreeSegments('exchange') + segments.track(undefined, node('a', 1, 'exchange')) + segments.settle([1]) + segments.track(node('a', 1, 'exchange'), undefined) + expect(segments.settle([])).toEqual([]) + expect(segments.inLatest(1)).toBe(false) + }) +}) diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index dcef2cdc43..4b6019fbe6 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -4,7 +4,7 @@ "messages": [ { "role": "system", - "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the complete source document from English to Chinese, producing natural, professional technical prose.\n\nRead each complete semantic unit, understand it, and restate it as a native technical author would write it in the target language. Do not mechanically preserve source-language syntax. Then verify the translation against the source clause by clause: preserve every proposition and add none. Fluency never justifies losing or altering meaning, and completeness never justifies unnatural word-for-word prose.\n\n## Priority\n\nApply these authorities in order:\n\n1. Preserve the source meaning and the required document structure, protected content, and formatting.\n2. Follow the injected terminology table exactly.\n3. Use the injected whole-document gold pairs to calibrate target-language voice and phrasing.\n4. Apply the general writing guidance and illustrative examples in this prompt.\n\nA lower-priority rule may refine but never override a higher-priority requirement. Gold pairs calibrate voice; they are not a translation memory. No style preference, gold-pair phrasing, or embedded example may override source meaning, required structure, protected content, or the terminology table.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains the same document frame as the source: heading hierarchy and order, list kinds and item counts, ordered-list starts, table rows and columns, link order and semantic targets, and code blocks.\n- Paragraph boundaries may change within the same structural unit when the target language needs different semantic grouping. Do not merge or move content across headings, list items, table cells, or other independent structural units.\n- Keep each prose paragraph on one physical line. Use paragraph breaks, not hard-wrapped lines inside a paragraph.\n- Fenced code blocks must be byte-identical to the source, including info strings, whitespace, and ALL comments inside them. Do NOT translate or reformat any content inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans must be kept verbatim. This includes commands, flags, paths, identifiers, API and event names, config keys, protocol values, version numbers, and other machine-readable tokens. Never translate or reformat them.\n- Every repository-relative document link must keep the source link's semantic target and exact query/fragment suffix. When the target belongs to the active bilingual corpus, English output uses its `.md` path and Chinese output uses its `.zh.md` path; a missing counterpart in that corpus is an error, while targets outside it keep the original path. External URLs, images, and pure in-page fragments stay unchanged. Translate link text.\n- Language switcher line: when an English source contains `English | [中文](source-filename.zh.md)`, write `[English](source-filename.md) | 中文`. When a Chinese source contains `[English](source-filename.md) | 中文`, write `English | [中文](source-filename.zh.md)`. Do NOT copy the source switcher unchanged. If the source has no switcher, do not invent a filename or switcher; the pipeline inserts the canonical target switcher after parsing ``.\n- Preserve emphasis marker types and the semantic spans they cover. Do not add, remove, move, or change bold and italic markers.\n\n### Faithfulness\n- Preserve every proposition in the source and add none. Every sentence, list item, note, FIXME, warning, example, caveat, prerequisite, and guarantee must have an equivalent in the translation. Count list items on both sides.\n- Preserve actors, objects, conditions, exceptions, negation, modality, causal relationships, and distinctions between concepts.\n- Preserve the exact strength and orientation of contracts. Completion and lifecycle conditions, failure behavior, directions and data flow, normal and exceptional result channels, ownership changes, and quantitative bounds must not be weakened, strengthened, reversed, or merged.\n- Translate ideas rather than source-language idioms, but never use fluency as a reason to omit or alter meaning.\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native technical author. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Name an actor when the target language would otherwise obscure an actor that the source states or unambiguously implies. Never invent responsibility merely to avoid a passive construction.\n- Prefer established target-language engineering terms over literal renderings. Replace metaphors with direct descriptions that preserve the source meaning.\n- Use polite imperative forms where the text instructs the reader to do something. In Chinese, address the reader as `你`, not `您`.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences where the target language needs a pause. Avoid run-on sentences.\n- Use active voice when it improves clarity without changing or inventing the actor. Retain passive voice when the actor is unknown, irrelevant, or intentionally omitted.\n- Restructure source-language syntax into clear target-language syntax. Preserve the logical scope of conditions, concessions, negation, coordination, and modifiers.\n- Split or combine clauses when needed for readability, provided every source relationship remains explicit.\n- Translate meaning, not words. Do not invent words or expressions that a native technical author would not use.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Translate ordinary prose when an established target-language expression is clear. Preserve proper nouns, canonical product names, code identifiers, APIs, paths, package names, and terms that the terminology table requires to remain in the source language.\n- Use context to resolve polysemous words. A familiar word does not have one fixed rendering in every technical domain.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate distinct source-language concepts when their distinction matters.\n- Avoid repeating the same ordinary verb in close proximity when a natural equivalent preserves the exact meaning. Never vary a terminology-table form, defined concept, or contract verb merely for stylistic variety.\n\n#### When translating into Chinese\n- When a number modifies a noun, include a natural Chinese classifier or measure word when Chinese grammar requires one. For example: \"three-role capability seam\" → \"包含三种角色的能力 seam\", not \"三角色 seam\". Do not add classifiers to code, identifiers, versions, units, or fixed names.\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in Chinese prose: `,。:;?!()「」`. Keep half-width punctuation inside code spans, numbers, and complete verbatim English text.\n- Prefer colons, periods, commas, or parentheses over em dashes when they make the sentence clearer or more natural. Keep an em dash when it is the clearest natural punctuation.\n- Use enumeration commas (、) between parallel Chinese items, not regular commas.\n- Keep list-item endings consistent with their grammar. Complete sentences may end with periods or other grammatically required punctuation; do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words or numerals. Do not add a space next to full-width punctuation, and do not leave a meaningless half-width space between two Chinese characters.\n- Markdown emphasis markers do not create a word boundary. Determine spacing from the rendered adjacent characters: Chinese next to Chinese takes no space, while Chinese next to a Latin word or numeral takes one half-width space.\n- Use half-width digits and Latin letters, never full-width forms.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以), preserve the SOURCE emphasis span exactly, and do not weaken its normative strength: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n- Use half-width English punctuation and standard English spacing. Preserve full-width punctuation only in verbatim Chinese text.\n- Convert enumeration commas (、) to English commas and Chinese prose quotation marks to English double quotes.\n- Convert Chinese topic-comment sentences and omitted-subject constructions into clear English subjects when the actor is stated or unambiguously implied. Do not invent an actor.\n- Use concise professional developer prose and established English technical terms. Do not transliterate Chinese engineering idioms literally.\n- Use the terminology table's English column exactly and do not carry Chinese first-occurrence glosses into English prose.\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On the document's first prose occurrence, write the \"首次出现\" value when one is specified; on later occurrences, write only the part before the parenthetical gloss.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- Code spans and other protected tokens remain verbatim even when their text resembles a listed term.\n- For an unlisted technical term, use an established target-language technical term when its meaning is unambiguous in context. For a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source; if you cannot reliably determine such a rendering, preserve the source term and record `[Terminology: pending]` in `` with a tentative rendering for human review. For an English target, use the established English technical term; if the source term has no unambiguous established equivalent, preserve it with the shortest English gloss needed to make it intelligible and record `[Terminology: pending]` in ``. A tentative rendering may appear in `` but must not be silently adopted in `` or ``, and you must not invent or claim a specific external precedent. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | 只指受支持的 Python 与 TypeScript SDK 所使用的 JSON-RPC 客户端/服务器协议;DeepSeek Harness 项目本身不是 SDK |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | | 智能体注记、智能体笔记 | 仓库定义的文档类型,涵盖提案、已实现决策和被否决提案;中文对侧 H1 保持固定前缀 `# Agent Note: `,标题中不加术语括注 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| coding agent | coding agent | coding agent(编程智能体) | | agent 组合词,正文保留英文 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| KV Cache | KV Cache | | | 专有技术名称,保持大小写与空格 |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | 接缝 | 一个可替换能力的整体,包含 Service Definition / Service Provider / Consumer 三种角色;角色需要独立演化时才拆包,也可由同一包承担多个角色。以 `packages/shell` 为范例;Service Definition 是 Cordis `Service`(抽象类或具体 registry 服务),不是 TypeScript interface。任何单一角色、普通边界或扩展点都不能称为 seam。本仓库正文保留英文;与 `extension point` 是不同概念 |\n| Service Provider | Service Provider | | Service provider | 能力 seam 的命名角色;单数固定写作 Service Provider,复数写作 Service Providers。泛指提供服务的 provider 不适用本词条 |\n| skill | skill | skill(技能) | | |\n| slot | slot | | 坑位、孔位 | 客户端架构中的具名可注册位置,保留英文 |\n| spill | spill | | | 工具输出超限落盘机制;组合词写 `spill 文件`、`spill 路径` |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| job id | job id | | 任务 id | 保留英文 |\n| subagent | subagent | | | 文档正文保留英文;中文 UI 中译作「子智能体」,不使用「子代理」 |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| Typert | Typert | | TypeRT、typeRT、Type RT | DeepSeek Harness 类型图、生成器、loader 与运行时 registry 的产品拼写 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器约定 | 适配器约定(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | 制品 | |\n| backend | 后端 | | | |\n| binder | 绑定器 | | | 命名角色:把已声明接口绑定到调用方 context 或生命周期 |\n| config | 配置 | | | 命名角色:一个已解析配置值或边界严格的配置记录 |\n| controller | 控制器 | | | 命名角色:接受意图并改变一项既有领域或展示状态 |\n| directory | 目录 | | | 命名角色:暴露供发现或选择的条目及元数据 |\n| engine | 引擎 | | | 命名角色:实现领域算法或有状态执行模型 |\n| gateway | 网关 | | | 命名角色:适配进程、网络、RPC 或 API 边界 |\n| handle | 句柄 | | | 命名角色:引用并控制或观察一个实时资源 |\n| policy | 策略 | | | 命名角色:决定允许、选择、限制或观察什么 |\n| presenter | 展示转换器 | | | 命名角色:把领域值纯转换为渲染意图 |\n| resolver | 解析器 | | | 命名角色:根据输入计算或定位一个答案 |\n| store | 存储 | | | 命名角色:拥有一组数据并主要提供数据操作 |\n| background job | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| canary test | canary 测试 | | 金丝雀测试 | 本仓库保留 `canary` |\n| capability | 能力 | | | 必须与 `feature` → `功能` 区分 |\n| capability seam | 能力 seam | | 功能 seam、能力接缝 | 本仓库 Service Definition、Service Provider 与 Consumer 三种角色组成完整可替换能力的命名架构概念;普通 `seam` 仍按其词条处理 |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| composition bundle | 组合包 | | | 只约束应用或插件的组合语境,不约束所有 `bundle` |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | 消费者 | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| configurable-provider directory | 可配置提供方目录 | | | llm seam 中 `registerConfigurableProviders()` 维护的目录;沿用 Service Catalog →「服务目录」先例 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 约定 | | | 如:`pairing contract` →`配对约定` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| dormant | 休眠 | | 睡眠、蛰伏 | 指已声明可配置但当前未注册路由的提供方 |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| Executive summary | 摘要 | | | 事故复盘标题用语 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 按分节记录两侧 hash |\n| finish reason | 结束原因 | | | |\n| fold | 折叠区 | | | 配置界面语境:默认收起的字段分区(collapsed →「收起」)|\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供商 | | | 指为模型提供 API 的厂商或网关:模型设置页文案、用户指南 providers 与 `ui-settings-models` README 用「提供商」;llm seam 的开发者文档(可配置提供方目录、dormant 等)沿用「提供方」 |\n| model selection | 模型选择 | | 模型目标 | 面向 Agent 的提供方、模型和可选推理强度选择。 |\n| module | 模块 | | | |\n| non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| opt-out ratio | opt-out 比例 | | 退出检查比例 | |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| parent-subset grants | 父级子集授权 | | 父集合授权 | 指授权范围仅限于父级所持授权的子集 |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| postmortem | 事故复盘 | 事故复盘(postmortem) | 事后分析、事故记录 | 事故记录与分析文档;目录或路径中的 `postmortem` 保持代码形式 |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | 泛指的提供方(搜索、检查、settings 文件、subagent 等);模型厂商或网关见 model provider 行 |\n| provider-neutral | 提供方无关 | | 提供方中立 | |\n| quality gate | 质量门禁 | | | |\n| quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| same-world subprocess | 与宿主共享文件系统和内核的子进程 | | 同世界子进程 | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving interface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| setup card | 设置卡片 | | | 首次运行时代替行卡直接展开的配置卡 |\n| sidecar file | 伴随文件 | | | 指与文档同目录的普通伴随文件 |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | 事实来源、唯一来源 | |\n| spine | 主干 | | | |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| Summary | 概述 | | | 事故复盘标题用语 |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nReturn exactly three raw XML sections in the order shown below. Do not wrap the response in a Markdown code fence and do not add analysis or text before, between, or after the sections. The fence below only displays the required format; do not reproduce the fence.\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of ``, ``, ``, ``, ``, or ``, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n\n(First pass: the complete translation, written as natural target-language technical prose)\n\n\n\n(Second pass: actual corrections only, one correction per line with a category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- [Terminology: pending] source term → tentative rendering\n- 无修正\n\n\n\n(Complete final translation after corrections)\n\n```\n\n## Self-Review Instructions\n\nAfter writing ``, verify it in two directions. First re-read it in the target language only without comparing it with the source; this makes awkward phrasing easier to notice. Then compare it against the source clause by clause for completeness and exact meaning. Resolve doubts before writing ``; do not include reasoning transcripts, checks that passed, tentative suggestions, retractions, or no-op corrections.\n\n**Structure**\n- Are the heading hierarchy and order, list kind and item count, ordered-list start, table dimensions, and code block content identical to the source?\n- Are ALL comments and info strings inside code blocks left untranslated and byte-identical to the source?\n- Are inline code spans and machine-readable tokens verbatim?\n- Is an existing language switcher correctly flipped, and is no switcher or filename invented when the source lacks one?\n- Do links preserve their semantic targets and exact query/fragment suffixes while using target-locale paths, and are emphasis spans preserved?\n- Does spacing across emphasis boundaries follow the same Chinese/Latin/numeral rule as ordinary prose?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Faithfulness**\n- Clause by clause, is anything added, dropped, weakened, strengthened, reversed, merged, or re-bounded? Are list item counts identical on both sides?\n- Do actors, objects, conditions, exceptions, negation, modality, causal relationships, guarantees, contract directions, result channels, ownership changes, and quantities survive exactly?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native technical author?\n- Is there any colloquial, casual, overly informal, promotional, or metaphorical phrasing?\n- Are actors explicit where the target language needs them, without inventing responsibility?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that can safely become active, or active constructions that invent an actor?\n- Are conditions, concessions, negation, coordination, and modifiers scoped clearly?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Are ordinary prose words left untranslated despite an established target-language expression?\n- Does each polysemous word fit its local context?\n- Is the same target-language word used for distinct source concepts, or is a defined term varied merely to avoid repetition?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied to the true first prose occurrence, neither missing nor repeated? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- Do protected tokens remain untouched even when they resemble terminology entries?\n- For an unlisted term, does a Chinese target use an established Chinese rendering or preserve the source term as pending when no reliable rendering is known, and does an English target use the established English technical term or preserve only an ambiguous source term with the shortest necessary gloss and a pending notice?\n\n**Punctuation** (when target is Chinese)\n- Are punctuation, mixed-script spacing, quotation marks, Latin letters, and digits in their required forms?\n- Are there em dashes that make the sentence less clear and should be replaced, while natural em dashes remain intact?\n- Are list-item endings grammatically consistent, with none ending in commas?\n- Do RFC 2119 keywords preserve the source emphasis span and normative strength exactly?\n\nRecord actual corrections in ``, then output the corrected complete document in ``. If no correction or pending terminology notice is needed, write exactly `- 无修正` in `` and copy `` unchanged into ``. If `` contains only pending terminology notices, copy `` unchanged into ``.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions within the rule each example illustrates; examples do not override source context or higher-priority requirements.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that should block a new release. A release should not ship with an open FIXME unless reviewers explicitly agree the change can be merged anyway.`\n- Bad: `FIXME——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 FIXME 出门。`\n- Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to notice when you read the translation without comparing it with the source`\n- Bad: `不把译文和原文比较时,尴尬的措辞更容易被注意`\n- Good: `不对照原文阅读译文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" + "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the complete source document from English to Chinese, producing natural, professional technical prose.\n\nRead each complete semantic unit, understand it, and restate it as a native technical author would write it in the target language. Do not mechanically preserve source-language syntax. Then verify the translation against the source clause by clause: preserve every proposition and add none. Fluency never justifies losing or altering meaning, and completeness never justifies unnatural word-for-word prose.\n\n## Priority\n\nApply these authorities in order:\n\n1. Preserve the source meaning and the required document structure, protected content, and formatting.\n2. Follow the injected terminology table exactly.\n3. Use the injected whole-document gold pairs to calibrate target-language voice and phrasing.\n4. Apply the general writing guidance and illustrative examples in this prompt.\n\nA lower-priority rule may refine but never override a higher-priority requirement. Gold pairs calibrate voice; they are not a translation memory. No style preference, gold-pair phrasing, or embedded example may override source meaning, required structure, protected content, or the terminology table.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains the same document frame as the source: heading hierarchy and order, list kinds and item counts, ordered-list starts, table rows and columns, link order and semantic targets, and code blocks.\n- Paragraph boundaries may change within the same structural unit when the target language needs different semantic grouping. Do not merge or move content across headings, list items, table cells, or other independent structural units.\n- Keep each prose paragraph on one physical line. Use paragraph breaks, not hard-wrapped lines inside a paragraph.\n- Fenced code blocks must be byte-identical to the source, including info strings, whitespace, and ALL comments inside them. Do NOT translate or reformat any content inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans must be kept verbatim. This includes commands, flags, paths, identifiers, API and event names, config keys, protocol values, version numbers, and other machine-readable tokens. Never translate or reformat them.\n- Every repository-relative document link must keep the source link's semantic target and exact query/fragment suffix. When the target belongs to the active bilingual corpus, English output uses its `.md` path and Chinese output uses its `.zh.md` path; a missing counterpart in that corpus is an error, while targets outside it keep the original path. External URLs, images, and pure in-page fragments stay unchanged. Translate link text.\n- Language switcher line: when an English source contains `English | [中文](source-filename.zh.md)`, write `[English](source-filename.md) | 中文`. When a Chinese source contains `[English](source-filename.md) | 中文`, write `English | [中文](source-filename.zh.md)`. Do NOT copy the source switcher unchanged. If the source has no switcher, do not invent a filename or switcher; the pipeline inserts the canonical target switcher after parsing ``.\n- Preserve emphasis marker types and the semantic spans they cover. Do not add, remove, move, or change bold and italic markers.\n\n### Faithfulness\n- Preserve every proposition in the source and add none. Every sentence, list item, note, FIXME, warning, example, caveat, prerequisite, and guarantee must have an equivalent in the translation. Count list items on both sides.\n- Preserve actors, objects, conditions, exceptions, negation, modality, causal relationships, and distinctions between concepts.\n- Preserve the exact strength and orientation of contracts. Completion and lifecycle conditions, failure behavior, directions and data flow, normal and exceptional result channels, ownership changes, and quantitative bounds must not be weakened, strengthened, reversed, or merged.\n- Translate ideas rather than source-language idioms, but never use fluency as a reason to omit or alter meaning.\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native technical author. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Name an actor when the target language would otherwise obscure an actor that the source states or unambiguously implies. Never invent responsibility merely to avoid a passive construction.\n- Prefer established target-language engineering terms over literal renderings. Replace metaphors with direct descriptions that preserve the source meaning.\n- Use polite imperative forms where the text instructs the reader to do something. In Chinese, address the reader as `你`, not `您`.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences where the target language needs a pause. Avoid run-on sentences.\n- Use active voice when it improves clarity without changing or inventing the actor. Retain passive voice when the actor is unknown, irrelevant, or intentionally omitted.\n- Restructure source-language syntax into clear target-language syntax. Preserve the logical scope of conditions, concessions, negation, coordination, and modifiers.\n- Split or combine clauses when needed for readability, provided every source relationship remains explicit.\n- Translate meaning, not words. Do not invent words or expressions that a native technical author would not use.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Translate ordinary prose when an established target-language expression is clear. Preserve proper nouns, canonical product names, code identifiers, APIs, paths, package names, and terms that the terminology table requires to remain in the source language.\n- Use context to resolve polysemous words. A familiar word does not have one fixed rendering in every technical domain.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate distinct source-language concepts when their distinction matters.\n- Avoid repeating the same ordinary verb in close proximity when a natural equivalent preserves the exact meaning. Never vary a terminology-table form, defined concept, or contract verb merely for stylistic variety.\n\n#### When translating into Chinese\n- When a number modifies a noun, include a natural Chinese classifier or measure word when Chinese grammar requires one. For example: \"three-role capability seam\" → \"包含三种角色的能力 seam\", not \"三角色 seam\". Do not add classifiers to code, identifiers, versions, units, or fixed names.\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in Chinese prose: `,。:;?!()「」`. Keep half-width punctuation inside code spans, numbers, and complete verbatim English text.\n- Prefer colons, periods, commas, or parentheses over em dashes when they make the sentence clearer or more natural. Keep an em dash when it is the clearest natural punctuation.\n- Use enumeration commas (、) between parallel Chinese items, not regular commas.\n- Keep list-item endings consistent with their grammar. Complete sentences may end with periods or other grammatically required punctuation; do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words or numerals. Do not add a space next to full-width punctuation, and do not leave a meaningless half-width space between two Chinese characters.\n- Markdown emphasis markers do not create a word boundary. Determine spacing from the rendered adjacent characters: Chinese next to Chinese takes no space, while Chinese next to a Latin word or numeral takes one half-width space.\n- Use half-width digits and Latin letters, never full-width forms.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以), preserve the SOURCE emphasis span exactly, and do not weaken its normative strength: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n- Use half-width English punctuation and standard English spacing. Preserve full-width punctuation only in verbatim Chinese text.\n- Convert enumeration commas (、) to English commas and Chinese prose quotation marks to English double quotes.\n- Convert Chinese topic-comment sentences and omitted-subject constructions into clear English subjects when the actor is stated or unambiguously implied. Do not invent an actor.\n- Use concise professional developer prose and established English technical terms. Do not transliterate Chinese engineering idioms literally.\n- Use the terminology table's English column exactly and do not carry Chinese first-occurrence glosses into English prose.\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On the document's first prose occurrence, write the \"首次出现\" value when one is specified; on later occurrences, write only the part before the parenthetical gloss.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- Code spans and other protected tokens remain verbatim even when their text resembles a listed term.\n- For an unlisted technical term, use an established target-language technical term when its meaning is unambiguous in context. For a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source; if you cannot reliably determine such a rendering, preserve the source term and record `[Terminology: pending]` in `` with a tentative rendering for human review. For an English target, use the established English technical term; if the source term has no unambiguous established equivalent, preserve it with the shortest English gloss needed to make it intelligible and record `[Terminology: pending]` in ``. A tentative rendering may appear in `` but must not be silently adopted in `` or ``, and you must not invent or claim a specific external precedent. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | 只指受支持的 Python 与 TypeScript SDK 所使用的 JSON-RPC 客户端/服务器协议;DeepSeek Harness 项目本身不是 SDK |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | | 智能体注记、智能体笔记 | 仓库定义的文档类型,涵盖提案、已实现决策和被否决提案;中文对侧 H1 保持固定前缀 `# Agent Note: `,标题中不加术语括注 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| coding agent | coding agent | coding agent(编程智能体) | | agent 组合词,正文保留英文 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| KV Cache | KV Cache | | | 专有技术名称,保持大小写与空格 |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | 接缝 | 一个可替换能力的整体,包含 Service Definition / Service Provider / Consumer 三种角色;角色需要独立演化时才拆包,也可由同一包承担多个角色。以 `packages/shell` 为范例;Service Definition 是 Cordis `Service`(抽象类或具体 registry 服务),不是 TypeScript interface。任何单一角色、普通边界或扩展点都不能称为 seam。本仓库正文保留英文;与 `extension point` 是不同概念 |\n| Service Provider | Service Provider | | Service provider | 能力 seam 的命名角色;单数固定写作 Service Provider,复数写作 Service Providers。泛指提供服务的 provider 不适用本词条 |\n| skill | skill | skill(技能) | | |\n| slot | slot | | 坑位、孔位 | 客户端架构中的具名可注册位置,保留英文 |\n| spill | spill | | | 工具输出超限落盘机制;组合词写 `spill 文件`、`spill 路径` |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| job id | job id | | 任务 id | 保留英文 |\n| subagent | subagent | | | 文档正文保留英文;中文 UI 中译作「子智能体」,不使用「子代理」 |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| Typert | Typert | | TypeRT、typeRT、Type RT | DeepSeek Harness 类型图、生成器、loader 与运行时 registry 的产品拼写 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器约定 | 适配器约定(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | 制品 | |\n| backend | 后端 | | | |\n| binder | 绑定器 | | | 命名角色:把已声明接口绑定到调用方 context 或生命周期 |\n| config | 配置 | | | 命名角色:一个已解析配置值或边界严格的配置记录 |\n| controller | 控制器 | | | 命名角色:接受意图并改变一项既有领域或展示状态 |\n| directory | 目录 | | | 命名角色:暴露供发现或选择的条目及元数据 |\n| engine | 引擎 | | | 命名角色:实现领域算法或有状态执行模型 |\n| gateway | 网关 | | | 命名角色:适配进程、网络、RPC 或 API 边界 |\n| handle | 句柄 | | | 命名角色:引用并控制或观察一个实时资源 |\n| policy | 策略 | | | 命名角色:决定允许、选择、限制或观察什么 |\n| presenter | 展示转换器 | | | 命名角色:把领域值纯转换为渲染意图 |\n| resolver | 解析器 | | | 命名角色:根据输入计算或定位一个答案 |\n| store | 存储 | | | 命名角色:拥有一组数据并主要提供数据操作 |\n| background job | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| canary test | canary 测试 | | 金丝雀测试 | 本仓库保留 `canary` |\n| capability | 能力 | | | 必须与 `feature` → `功能` 区分 |\n| capability seam | 能力 seam | | 功能 seam、能力接缝 | 本仓库 Service Definition、Service Provider 与 Consumer 三种角色组成完整可替换能力的命名架构概念;普通 `seam` 仍按其词条处理 |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| composition bundle | 组合包 | | | 只约束应用或插件的组合语境,不约束所有 `bundle` |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | 消费者 | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| configurable-provider directory | 可配置提供方目录 | | | llm seam 中 `registerConfigurableProviders()` 维护的目录;沿用 Service Catalog →「服务目录」先例 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 约定 | | | 如:`pairing contract` →`配对约定` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| dormant | 休眠 | | 睡眠、蛰伏 | 指已声明可配置但当前未注册路由的提供方 |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| exchange | 交互段 | | 回合、交换 | Chat 中从真人输入开启的轮次起、到下一个这样的轮次为止的连续轮次;见术语表 |\n| Executive summary | 摘要 | | | 事故复盘标题用语 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 按分节记录两侧 hash |\n| finish reason | 结束原因 | | | |\n| fold | 折叠区 | | | 配置界面语境:默认收起的字段分区(collapsed →「收起」)|\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供商 | | | 指为模型提供 API 的厂商或网关:模型设置页文案、用户指南 providers 与 `ui-settings-models` README 用「提供商」;llm seam 的开发者文档(可配置提供方目录、dormant 等)沿用「提供方」 |\n| model selection | 模型选择 | | 模型目标 | 面向 Agent 的提供方、模型和可选推理强度选择。 |\n| module | 模块 | | | |\n| non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| opt-out ratio | opt-out 比例 | | 退出检查比例 | |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| parent-subset grants | 父级子集授权 | | 父集合授权 | 指授权范围仅限于父级所持授权的子集 |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| postmortem | 事故复盘 | 事故复盘(postmortem) | 事后分析、事故记录 | 事故记录与分析文档;目录或路径中的 `postmortem` 保持代码形式 |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | 泛指的提供方(搜索、检查、settings 文件、subagent 等);模型厂商或网关见 model provider 行 |\n| provider-neutral | 提供方无关 | | 提供方中立 | |\n| quality gate | 质量门禁 | | | |\n| quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| same-world subprocess | 与宿主共享文件系统和内核的子进程 | | 同世界子进程 | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving interface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| setup card | 设置卡片 | | | 首次运行时代替行卡直接展开的配置卡 |\n| sidecar file | 伴随文件 | | | 指与文档同目录的普通伴随文件 |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | 事实来源、唯一来源 | |\n| spine | 主干 | | | |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| Summary | 概述 | | | 事故复盘标题用语 |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nReturn exactly three raw XML sections in the order shown below. Do not wrap the response in a Markdown code fence and do not add analysis or text before, between, or after the sections. The fence below only displays the required format; do not reproduce the fence.\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of ``, ``, ``, ``, ``, or ``, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n\n(First pass: the complete translation, written as natural target-language technical prose)\n\n\n\n(Second pass: actual corrections only, one correction per line with a category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- [Terminology: pending] source term → tentative rendering\n- 无修正\n\n\n\n(Complete final translation after corrections)\n\n```\n\n## Self-Review Instructions\n\nAfter writing ``, verify it in two directions. First re-read it in the target language only without comparing it with the source; this makes awkward phrasing easier to notice. Then compare it against the source clause by clause for completeness and exact meaning. Resolve doubts before writing ``; do not include reasoning transcripts, checks that passed, tentative suggestions, retractions, or no-op corrections.\n\n**Structure**\n- Are the heading hierarchy and order, list kind and item count, ordered-list start, table dimensions, and code block content identical to the source?\n- Are ALL comments and info strings inside code blocks left untranslated and byte-identical to the source?\n- Are inline code spans and machine-readable tokens verbatim?\n- Is an existing language switcher correctly flipped, and is no switcher or filename invented when the source lacks one?\n- Do links preserve their semantic targets and exact query/fragment suffixes while using target-locale paths, and are emphasis spans preserved?\n- Does spacing across emphasis boundaries follow the same Chinese/Latin/numeral rule as ordinary prose?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Faithfulness**\n- Clause by clause, is anything added, dropped, weakened, strengthened, reversed, merged, or re-bounded? Are list item counts identical on both sides?\n- Do actors, objects, conditions, exceptions, negation, modality, causal relationships, guarantees, contract directions, result channels, ownership changes, and quantities survive exactly?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native technical author?\n- Is there any colloquial, casual, overly informal, promotional, or metaphorical phrasing?\n- Are actors explicit where the target language needs them, without inventing responsibility?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that can safely become active, or active constructions that invent an actor?\n- Are conditions, concessions, negation, coordination, and modifiers scoped clearly?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Are ordinary prose words left untranslated despite an established target-language expression?\n- Does each polysemous word fit its local context?\n- Is the same target-language word used for distinct source concepts, or is a defined term varied merely to avoid repetition?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied to the true first prose occurrence, neither missing nor repeated? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- Do protected tokens remain untouched even when they resemble terminology entries?\n- For an unlisted term, does a Chinese target use an established Chinese rendering or preserve the source term as pending when no reliable rendering is known, and does an English target use the established English technical term or preserve only an ambiguous source term with the shortest necessary gloss and a pending notice?\n\n**Punctuation** (when target is Chinese)\n- Are punctuation, mixed-script spacing, quotation marks, Latin letters, and digits in their required forms?\n- Are there em dashes that make the sentence less clear and should be replaced, while natural em dashes remain intact?\n- Are list-item endings grammatically consistent, with none ending in commas?\n- Do RFC 2119 keywords preserve the source emphasis span and normative strength exactly?\n\nRecord actual corrections in ``, then output the corrected complete document in ``. If no correction or pending terminology notice is needed, write exactly `- 无修正` in `` and copy `` unchanged into ``. If `` contains only pending terminology notices, copy `` unchanged into ``.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions within the rule each example illustrates; examples do not override source context or higher-priority requirements.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that should block a new release. A release should not ship with an open FIXME unless reviewers explicitly agree the change can be merged anyway.`\n- Bad: `FIXME——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 FIXME 出门。`\n- Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to notice when you read the translation without comparing it with the source`\n- Bad: `不把译文和原文比较时,尴尬的措辞更容易被注意`\n- Good: `不对照原文阅读译文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" }, { "role": "user", diff --git a/tsconfig.host.json b/tsconfig.host.json index eb59077907..0e6f2c058d 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -9,6 +9,7 @@ }, "include": [ "apps/web/tests/collapse-timing.e2e.ts", + "apps/web/tests/notice-wake-tail.e2e.ts", "apps/web/tests/chat-flow-layout.e2e.ts", "apps/web/product-isolation.ts", "apps/web/katex-fonts.ts",