diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.i18n.yaml new file mode 100644 index 0000000000..41b52e29c7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-26-code-mode-chat-subcall-rows.md: 7d666f0a9e4b8bdb9bd6f5d0d0984fee0c4b21e2 +2026-07-26-code-mode-chat-subcall-rows.zh.md: fb9b0c62bb702cfdb7ba3c8ccce73d8e43f29c1b diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.md b/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.md new file mode 100644 index 0000000000..7d666f0a9e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.md @@ -0,0 +1,32 @@ +# Agent Note: Code Mode chat rendering — sub-calls as native rows under the parent + +Status: implemented + +English | [中文](2026-07-26-code-mode-chat-subcall-rows.zh.md) + +> Scope: how the web chat view renders a `run_code` turn — the client-side half of the Code Mode UI stack, built on the [host foundation](2026-07-26-code-dispatch-ui-foundation.md) (full-content `tool/code-dispatch`, the required `description` parameter). The [toolview dissolution](../architecture/2026-07-23-toolview-dissolution.md) owns the slot model this rides on. + +## Problem + +With Code Mode enabled, the chat view showed one opaque `run_code` row: raw program text as the summary, sub-calls invisible everywhere. The settled product requirement is the opposite: each sub-call must render *identically* to a native tool call — same row components, same custom registrations, same details panel — while the transcript stays honest about the fact that the model made ONE call. + +## Decision + +**Sub-calls are `ToolResultNode`s indexed off the surface flow, rendered through the same keyed slot as native rows, nested always-visible under their parent.** + +- **Data layer**: `Session.applyEventSideEffects` folds each in-window `tool/code-dispatch` into `ConversationSnapshot.codeDispatches: ReadonlyMap`, where `CodeSubCall` IS `ToolResultNode` (the sub-call id as `callId`, the logged args JSON-stringified into `call.argsRaw`, the full logged `content`/`isError`). Live mux frames and history replay build the identical index (`rebuildDerivedFromWindow` clears and re-derives; copy-on-write per-parent arrays keep snapshot references memo-stable). Sub-calls never join `nodes` — the surface flow remains exactly the model-visible turn structure. The event is narrowed structurally at the wire-consumer boundary (dsh-tools' host types cannot enter the client program — the host/client `Context` merges collide), the same posture as every cross-wire payload. +- **Render layer**: `ChatView`'s `CallRow` renders the parent, then — for parents present in the index — a `[data-subcalls]` nest of `SubCallRow`s, each dispatching through the SAME `'conversation.chat.toolview'` keyed hole with `entryKey = sub-tool name` and the same `GenericToolCard` fallback. Identity with native rows holds by construction: a keyed registration (e.g. the bash sample) takes over sub-rows exactly as it takes over top-level rows, with zero registration changes. Running parents (`runningCalls`) nest their so-far dispatches the same way, so sub-rows stream in live during the run (PR1 logs each dispatch as it completes). +- **`run_code` presentation**: a new `code` row variant (classifier `run_code → code`, `Code` title, `IconCodeOutline16`) summarizes with the model-authored `description` and expands to the program itself (monospace on the markdown code-block fill) rather than the args JSON envelope. +- **Details panel**: `materialFor` falls through nodes → runningCalls → the dispatch index, so a selected sub-callId resolves to full args and complete output through the identical rendering path as a native settled call. + +## Alternatives considered + +**Sub-calls flat in the surface flow (fold them into `nodes`).** Rejected: misrepresents the transcript — the model made one call; nesting under the parent preserves the code↔calls association and keeps the fold's model-visible-order invariant untouched. + +**Hidden until the parent row expands.** Rejected by product decision: the sub-calls ARE the story of a Code Mode turn; hiding them re-creates the opacity this feature removes. The parent's expand toggle reveals only the program. + +**A dedicated sub-call row component.** Rejected: the whole point is identity with native rows; a parallel component would drift. The nest wrapper (indent + left edge) is the only sub-call-specific chrome. + +## Consequences + +Custom toolview registrations apply to sub-calls for free — and deliberately: there is no per-registration opt-out short of the component reading its own context, which no current consumer needs. Selection highlighting reaches nested rows through the same `selectedCallId` channel (group membership tests both levels). Trajectory/waterfall still render `run_code` as a single row — their sub-call spans are deferred to the PR that adds dispatch timing (start/end events), without which a waterfall span would be a lie. Fixture turn 64 (`?fixture`) plus the `code-mode-round` browser e2e (recorded real round, keyless replay) pin the full surface; the jsdom suites pin the slot dispatch, error states, details resolution, and index reference stability. diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.zh.md b/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.zh.md new file mode 100644 index 0000000000..fb9b0c62bb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.zh.md @@ -0,0 +1,32 @@ +# Agent Note:Code Mode 的 chat 渲染——子调用作为父行之下的原生行 + +Status: implemented + +[English](2026-07-26-code-mode-chat-subcall-rows.md) | 中文 + +> 范围:web chat 视图如何渲染一个 `run_code` 轮次,即 Code Mode UI 堆叠 PR(Pull Request)链的 client 侧一半,构建在[宿主侧基础](2026-07-26-code-dispatch-ui-foundation.md)之上(携带完整内容的 `tool/code-dispatch`、必填的 `description` 参数)。本篇所依托的 slot 模型归 [toolview 溶解](../architecture/2026-07-23-toolview-dissolution.md)所有。 + +## 问题 + +启用 Code Mode 后,chat 视图过去只显示一条不透明的 `run_code` 行:摘要就是原始程序文本,子调用则处处不可见。已敲定的产品要求恰恰相反:每个子调用都必须与原生工具调用渲染得*完全一致*——同样的行组件、同样的自定义注册、同样的 details 面板——同时 transcript(文本记录)仍须如实反映模型只发起了一次调用这一事实。 + +## 决策 + +**子调用是 surface 流之外单独索引的 `ToolResultNode`,经由与原生行相同的 keyed slot 渲染,以始终可见的方式嵌套在父行之下。** + +- **数据层**:`Session.applyEventSideEffects` 把窗口内的每条 `tool/code-dispatch` 折入 `ConversationSnapshot.codeDispatches: ReadonlyMap`,其中 `CodeSubCall` 本身就是 `ToolResultNode`(子调用 id 充当 `callId`,已记录的参数经 JSON 字符串化写入 `call.argsRaw`,完整记录的 `content`/`isError` 原样携带)。live mux 帧与历史回放构建出同一份索引(`rebuildDerivedFromWindow` 先清空再重新推导;逐父级的写时复制(copy-on-write)数组保持快照引用 memo 稳定)。子调用永不进入 `nodes`——surface 流始终精确等于模型可见的轮次结构。该事件在 wire 消费方边界作结构性收窄(dsh-tools 的 host 类型进不了 client 程序——host/client 两侧的 `Context` 声明合并会冲突),姿态与所有跨 wire 载荷一致。 +- **渲染层**:`ChatView` 的 `CallRow` 先渲染父行,随后对索引中出现的父级渲染一组 `[data-subcalls]` 嵌套的 `SubCallRow`,每一行都经由同一个 `'conversation.chat.toolview'` keyed 孔位、以 `entryKey = sub-tool name` 分发,并共用同一个 `GenericToolCard` fallback。与原生行的同一性由构造保证:一个 keyed 注册(例如 bash 样例)接管子行与接管顶层行的方式完全相同,注册本身零改动。运行中的父调用(`runningCalls`)也以同样的方式嵌套目前已产生的分发,因此子行在运行期间实时流入(PR1 在每次分发完成时即记录该分发)。 +- **`run_code` 的呈现**:新增一种 `code` 行变体(分类器映射 `run_code → code`、标题 `Code`、图标 `IconCodeOutline16`),以模型撰写的 `description` 作摘要,展开后显示程序本身(在 markdown 代码块的填充底色上以等宽字体呈现),而非参数的 JSON 信封。 +- **details 面板**:`materialFor` 按 nodes → runningCalls → 分发索引的顺序逐级回落,因此被选中的子调用 callId 会经由与已完结的原生调用完全相同的渲染路径,解析出完整参数与完整输出。 + +## 曾考虑的替代方案 + +**把子调用平铺进 surface 流(折入 `nodes`)。** 否决:这会歪曲 transcript——模型只发起了一次调用;嵌套在父行之下既保住代码↔调用的关联,也让 fold 的模型可见顺序不变式原封不动。 + +**隐藏子调用,展开父行后才显示。** 由产品决策否决:子调用正是一个 Code Mode 轮次的核心内容;把它们藏起来,等于重新制造出本功能所要消除的那种不透明。父行的展开开关只用于显示程序本身。 + +**专用的子调用行组件。** 否决:本功能的全部要义就在于与原生行保持同一性;一个平行组件必然漂移。嵌套包装层(缩进 + 左侧边线)是子调用唯一的专属 chrome。 + +## 后果 + +自定义 toolview 注册免费适用于子调用——而且是刻意为之:不存在按注册粒度的 opt-out,唯一的出路是组件自行读取自身上下文,而当前没有任何消费方需要这么做。选中高亮经由同一条 `selectedCallId` 通道到达嵌套行(分组归属判断会同时检验两个层级)。trajectory/waterfall 仍把 `run_code` 渲染为单独一行——它们的子调用 span 推迟到增加分发计时(start/end 事件)的那个 PR;缺少计时,waterfall 上的 span 就是在撒谎。fixture(测试前置数据)的轮次 64(`?fixture`),加上 `code-mode-round` 浏览器 e2e(录制的真实 round、无密钥回放),共同锁定整个表面;jsdom 套件则锁定 slot 分发、错误状态、details 解析与索引引用稳定性。 diff --git a/apps/web/tests/code-mode-fixture.snapshot.ts b/apps/web/tests/code-mode-fixture.snapshot.ts new file mode 100644 index 0000000000..46549179be --- /dev/null +++ b/apps/web/tests/code-mode-fixture.snapshot.ts @@ -0,0 +1,188 @@ +// @vitest-environment jsdom +// Code Mode fixture snapshot over the BUILT client graph (the workspace-flow +// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport). +// Opens the fixture history session and pins the run_code turn's rendering: +// the code-variant parent row titled by the model-authored description, its +// three always-visible nested sub-rows (bash through the sample registration, +// read through GenericToolCard, the failing read wearing the error state), +// the expanded program body, and details-panel resolution of a sub-callId. +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client' +import { AppWebEntry } from '@deepseek-ai/dsh-client-web' + +const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ + { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, + { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { + id: '@deepseek-ai/dsh-client-ui-workspace', + dir: 'ui-workspace', + url: '/plugins/ui-workspace.js', + rev: 'fx', + inject: [ + '@deepseek-ai/dsh-client-runtime', + '@deepseek-ai/dsh-client-ui-conversation', + '@deepseek-ai/dsh-client-ui-sidebar', + ], + }, + { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, +] + +const bundles = new Map(PLUGINS.map(plugin => [ + plugin.url, + readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'), +])) + +interface FixtureWindow extends Window { + __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] } + __ModuleLoader__?: unknown +} + +class ResizeObserverStub { + observe(): void {} + disconnect(): void {} + unobserve(): void {} +} + +const win = window as FixtureWindow +let unmount: (() => void) | undefined + +beforeEach(() => { + localStorage.clear() + document.title = 'DeepSeek Harness' + vi.stubGlobal('ResizeObserver', ResizeObserverStub) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + setTimeout(() => { callback(0) }, 0) as unknown as number) + vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) }) +}) + +afterEach(() => { + act(() => { unmount?.() }) + unmount = undefined + cleanup() + delete win.__DSH_BOOT__ + delete win.__ModuleLoader__ + delete (globalThis as Record).__fxTiming + document.body.innerHTML = '' + document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() }) + document.title = '' + history.replaceState(null, '', '/') + vi.unstubAllGlobals() +}) + +/** Boot the complete built client graph against the populated fixture branch. */ +function boot(): void { + history.replaceState(null, '', '/?fixture') + const root = document.createElement('div') + root.id = 'root' + document.body.appendChild(root) + win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) } + act(() => { + const entry = new AppWebEntry(root, { + fetchBundle: (url) => { + const code = bundles.get(url) + return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code) + }, + executeBundle: (code) => { (0, eval)(code) }, + }) + void entry.run() + unmount = () => { entry.dispose() } + }) +} + +/** Collapse decorative whitespace while preserving the text a user sees. */ +function visibleText(element: Element): string { + return (element.textContent ?? '').replace(/\s+/g, ' ').trim() +} + +/** Open the fixture history session (the alpha log carrying the run_code turn) and scroll to its tail. */ +async function openFixtureSession(): Promise { + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + const group = within(tree).getByText('4 sessions').closest('[role="treeitem"]') + if (group === null) throw new Error('fixture Workspace group missing') + if (group.getAttribute('aria-expanded') === 'false') { + fireEvent.click(within(group).getByText('fixture')) + await waitFor(() => { + expect(within(tree).getByText('4 sessions').closest('[role="treeitem"]')?.getAttribute('aria-expanded')).toBe('true') + }) + } + const session = await within(tree).findByText('Fixture 历史会话') + fireEvent.click(session) + await waitFor(() => { + expect(document.querySelector('[data-variant="code"]')).not.toBeNull() + }, { timeout: 10_000 }) +} + +it('renders the fixture run_code turn: code parent row, nested sub-rows, error state', async () => { + boot() + await openFixtureSession() + + const codeRoot = document.querySelector('[data-variant="code"]') + if (codeRoot === null) throw new Error('code-variant row missing') + const nest = codeRoot.closest('[class*="callRow"]')?.querySelector('[data-subcalls]') + if (nest === undefined || nest === null) throw new Error('sub-call nest missing under the code row') + + expect({ + parentRow: visibleText(codeRoot), + // The three sub-rows in dispatch order: bash rides the sample plugin's + // keyed registration (the same one a native top-level bash row uses), + // both reads ride GenericToolCard. + bashSample: nest.querySelector('[data-sample="bash-global"]') !== null, + subRows: [...nest.querySelectorAll(':scope > *')].map(visibleText), + errorSubRow: nest.querySelector('[data-state="error"]') !== null, + }).toMatchInlineSnapshot(` + { + "bashSample": true, + "errorSubRow": true, + "parentRow": "CodeRead the notes files and summarize", + "subRows": [ + "$List notes", + "Readnotes/demo.txt", + "Readnotes/missing.txt", + ], + } + `) +}) + +it('expands the code row into the program body and resolves a sub-row through the details panel', async () => { + boot() + await openFixtureSession() + + // Expand: the leading control reveals the program verbatim. + const codeRoot = document.querySelector('[data-variant="code"]') + if (codeRoot === null) throw new Error('code-variant row missing') + const toggle = codeRoot.querySelector('button[aria-expanded]') + if (toggle === null) throw new Error('code row expand control missing') + fireEvent.click(toggle) + await screen.findByText(/const listing = await tools\.bash/) + + // Sub-row click → details panel resolves the sub-callId with FULL output. + const nest = document.querySelector('[data-subcalls]') + if (nest === null) throw new Error('sub-call nest missing') + const bashRow = nest.querySelector('[data-sample="bash-global"]') + if (bashRow === null) throw new Error('bash sample sub-row missing') + fireEvent.click(bashRow) + const details = await screen.findByText('Input') + const panel = details.closest('[class*="root"]') + if (panel === null) throw new Error('details panel missing') + expect({ + title: visibleText(within(panel as HTMLElement).getByText('bash')), + inputEchoesArgs: visibleText(panel).includes('ls notes'), + outputComplete: visibleText(panel).includes('demo.txt new-demo.txt') + || visibleText(panel).includes('demo.txt\nnew-demo.txt') + || (panel.textContent ?? '').includes('demo.txt\nnew-demo.txt'), + }).toMatchInlineSnapshot(` + { + "inputEchoesArgs": true, + "outputComplete": true, + "title": "bash", + } + `) +}) diff --git a/apps/web/tests/code-mode-round.e2e.ts b/apps/web/tests/code-mode-round.e2e.ts new file mode 100644 index 0000000000..8b25bad5ca --- /dev/null +++ b/apps/web/tests/code-mode-round.e2e.ts @@ -0,0 +1,144 @@ +// Web e2e scenario: a Code Mode round trip. The scaffold boots the SAME +// shipped tree with the tools row patched to mode: code (the run_code-only +// wire), a real chromium sends a prompt engineered to elicit one run_code +// program with several sub-calls, and the UI must render the code-variant +// parent row with its always-visible nested sub-rows — each sub-row the same +// component a native call renders through — plus details-panel resolution for +// a clicked sub-row. Drive steps wait only on generic completion +// (whenTurnSettled); assertion steps run in replay/refresh only. +// Record: DSH_SNAPSHOT=record rewrites session.jsonl, then a keyless +// DSH_SNAPSHOT=refresh regenerates ui.expected.md. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { + captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { saveFailureShot } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('./snapshots/code-mode-round/session.jsonl', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('./snapshots/code-mode-round/ui.expected.md', import.meta.url)) +const MODE = webSnapshotMode() + +// The scenario's one drive prompt: elicits one program with a bash sub-call +// and a failing read the program tolerates — the sub-row set the assertions +// (and the PR gif) need. Never asserted against model prose. +const PROMPT = 'Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt ' + + 'catching its error in the program. Return an object with both outcomes. Then reply DONE and stop.' + +describe('web e2e: Code Mode round renders nested sub-calls', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + const sessionEvents: SessionEvent[] = [] + + beforeAll(async () => { + scaffold = await launchWebScaffold({ + toolsMode: 'code', + ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }), + }) + scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('drives the recorded prompt to a settled turn (all modes)', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-drive')) + if (MODE !== 'record') { + // Drift guard: the committed fixture must carry exactly the drive prompt. + expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + } + const input = page.locator('textarea').first() + await input.waitFor({ timeout: 10_000 }) + const settled = scaffold.whenTurnSettled() + await input.fill(PROMPT) + await input.press('Enter') + const sessionId = await settled + if (MODE === 'record') { + await recordFixture(scaffold, sessionId, FIXTURE) + } + }, 200_000) + + it.skipIf(MODE === 'record')('the durable log carries run_code with full-content sub-dispatches', () => { + // Wire discipline: code mode collapsed the call surface to run_code. + const calls = sessionEvents.filter(event => event.type === 'tool/call') + expect(calls.length).toBeGreaterThanOrEqual(1) + expect(new Set(calls.map(call => (call.data as { name: string }).name))).toEqual(new Set(['run_code'])) + // Sub-dispatches logged with the complete tool/result vocabulary. + const dispatches = sessionEvents.filter(event => (event.type as string) === 'tool/code-dispatch') + expect(dispatches.length).toBeGreaterThanOrEqual(2) + for (const dispatch of dispatches) { + const data = dispatch.data as unknown as { + parentCallId: string + subCallId: string + name: string + isError: boolean + content: { type: string }[] + } + expect(data.subCallId.startsWith(`${data.parentCallId}:code:`)).toBe(true) + expect(Array.isArray(data.content)).toBe(true) + expect(typeof data.isError).toBe('boolean') + } + const bash = dispatches.find(dispatch => (dispatch.data as { name: string }).name === 'bash') + expect(bash).toBeDefined() + const bashContent = (bash!.data as { content: { type: string; text?: string }[] }).content + expect(bashContent.filter(block => block.type === 'text').map(block => block.text).join('')).toContain('CODE_ROUND_OK') + }) + + it.skipIf(MODE === 'record')('renders the code parent row with always-visible nested sub-rows', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-rows')) + await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) + // The parent run_code row wears the code variant with the model-authored + // description as its summary (the PR1 presentCall contract). + const codeRow = page.locator('[data-variant="code"]').first() + await codeRow.waitFor({ timeout: 10_000 }) + // Nested rows are visible WITHOUT any expand interaction, inside the + // sub-call nest, each rendered by the same components as native rows: + // the bash sub-call landed in the bash sample registration. + const nest = page.locator('[data-subcalls]').first() + await nest.waitFor({ timeout: 10_000 }) + expect(await nest.locator('[data-sample="bash-global"]').count()).toBeGreaterThanOrEqual(1) + // The failing read sub-call wears the same error state a native failed + // row wears (the recorded program tolerates a read of missing.txt). + expect(await nest.locator('[data-state="error"]').count()).toBeGreaterThanOrEqual(1) + }, 60_000) + + it.skipIf(MODE === 'record')('a sub-row click opens the details panel on the sub-call material', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-details')) + const nest = page.locator('[data-subcalls]').first() + await nest.locator('[data-sample="bash-global"]').first().click() + // The details column opens (width > 0) and shows the sub-call's complete + // output — the full-content log contract, no truncation marker anywhere. + await page.waitForFunction(() => { + const frame = document.querySelector('[class*="frame"]') + if (frame === null) return false + return Number(getComputedStyle(frame).gridTemplateColumns.split(' ').pop()!.replace('px', '')) > 0 + }, undefined, { timeout: 10_000 }) + await expect.poll(() => page.getByText('CODE_ROUND_OK', { exact: false }).count(), { timeout: 5_000 }) + .toBeGreaterThanOrEqual(1) + }) + + it.skipIf(MODE === 'record')('matches the conversation aria golden with stable anchors', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-aria')) + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + }) + + it.skipIf(MODE === 'record')('stayed clean: no page errors, no reconnect churn', () => { + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index f0d1e99266..98f5124490 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -109,6 +109,13 @@ export interface LaunchOptions { replayOverride?: string /** Per-chunk replay pacing (ms) so the browser observes genuinely incremental SSE; replay/refresh only. */ paceMs?: number + /** + * Tool presentation mode patched onto the shipped `tools` row (`code` + * collapses the wire to run_code + the SDK prompt section). Omit for the + * yml default. The code runtime row is always in the tree, so no extra + * insertion is needed. + */ + toolsMode?: 'native' | 'code' | 'both' } /** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */ @@ -160,6 +167,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise "tolerated")\n' + + 'return { listing, demo }' + const args = JSON.stringify({ code: program, description: 'Read the notes files and summarize' }) + push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) + push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}:run_code 样本。`), source: { kind: 'user' } } }) + push({ type: 'step/start', data: { turn, step: 0 } }) + push({ + type: 'assistant/message', surfaceOp: 'append', + data: { turn, step: 0, content: [{ type: 'tool-call', id: callId, name: 'run_code', arguments: args } as ContentBlock], provenance: { provider: 'fixture', model: 'fx-1' } }, + }) + push({ type: 'tool/call', data: { turn, step: 0, callId, name: 'run_code', arguments: args } }) + push({ + type: 'tool/code-dispatch', + data: { + parentCallId: callId, subCallId: `${callId}:code:1`, name: 'bash', + arguments: { command: 'ls notes', description: 'List notes' }, + isError: false, content: [{ type: 'text', text: 'demo.txt\nnew-demo.txt' }], + }, + }) + push({ + type: 'tool/code-dispatch', + data: { + parentCallId: callId, subCallId: `${callId}:code:2`, name: 'read', + arguments: { path: 'notes/demo.txt' }, + isError: false, content: [{ type: 'text', text: 'hello fixture\n' }], + }, + }) + push({ + type: 'tool/code-dispatch', + data: { + parentCallId: callId, subCallId: `${callId}:code:3`, name: 'read', + arguments: { path: 'notes/missing.txt' }, + isError: true, content: [{ type: 'text', text: 'Error: ENOENT: notes/missing.txt not found' }], + }, + }) + push({ + type: 'tool/result', surfaceOp: 'append', + data: { turn, step: 0, callId, content: text('{"listing":"demo.txt\\nnew-demo.txt","demo":"hello fixture\\n"}'), isError: false }, + }) + push({ type: 'step/end', data: { turn, step: 0 } }) + push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) + } return events as unknown as SessionEvent[] } diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 2ac9ca2696..388a1e9ed3 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 5d44f666a12e747eb79535c48d87fcaff381d770 -README.zh.md: 16577c356ba79066c7c56ae07a266f4ec85fa2e9 +README.md: f37216a88c78a93a561c919bc23f5e728ba666c6 +README.zh.md: 8ea65a0ce2aaa89f6a9c4d5a3c5215a46dbc27a7 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 5d44f666a1..f37216a88c 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -14,6 +14,10 @@ SlotsService gives the renderer separate bare observables for `useSessions` and `SessionsService.create` accepts an optional caller-preallocated SessionId. It throws `SessionCreateError` on failure: `requestedSessionId` remains available after transport uncertainty, while `publishedSessionId` is set when `workspace-attach-failed` proves the Host published a real Session before attachment failed. For the New Session flow, the frontend Session object owns its retained prompt and advances it through attachment and send; a partially published Session keeps the same object and prompt while it appears as Ungrouped. +## Code Mode sub-dispatch index + +`ConversationSnapshot.codeDispatches` groups a `run_code` call's sub-dispatches under their parent callId, in dispatch order, as settled `ToolResultNode` entries (the native result shape): each `tool/code-dispatch` event appends one. The event carries only the settle timestamp, so `callTime` is `null` (start unknown) — no duration claim is possible from this index yet. Live mux frames and history replay build the identical index; sub-calls never join the surface `nodes` flow; per-parent array and map references are memo-stable across unrelated snapshot swaps. + ## Session title projection `SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 16577c356b..8ea65a0ce2 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -14,6 +14,10 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 `SessionsService.create` 接受可选的、由调用方预先分配的 SessionId。失败时抛出 `SessionCreateError`:传输状态不确定后仍可取得 `requestedSessionId`;如果 Host 在附加失败前已经发布真实 Session,则会设置 `publishedSessionId`,此时 `workspace-attach-failed` 提供了证明。在 New Session 流程中,前端 Session 对象拥有其保留的提示词,并推动提示词完成附加与发送;部分发布的 Session 会保留同一对象和提示词,同时显示为 Ungrouped。 +## Code Mode 子调用索引 + +`ConversationSnapshot.codeDispatches` 按父调用的 callId 和分发顺序,将一个 `run_code` 调用的子调用组织为已完结的 `ToolResultNode` 条目(即原生结果形状):每条 `tool/code-dispatch` 事件追加一个。该事件只携带完结时间戳,因此 `callTime` 为 `null`(起始时间未知);此索引目前无法据此作出任何耗时声明。live mux 帧与历史回放构建相同的索引;子调用永不进入 surface `nodes` 流;无关快照交换不会改变每个父调用对应的数组引用和映射引用,两者均保持 memo 稳定。 + ## Session 标题投影 `SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更新的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含真实的持久标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷启动的持久会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影日志支持的标题。 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 830d1b8249..ca059455e8 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -24,7 +24,7 @@ export type { EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore, } from './contract/store.ts' export type { - AssistantBlock, AssistantMessageNode, ComposerPhase, ContextMessageNode, ConversationNode, + AssistantBlock, AssistantMessageNode, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode, ConversationSnapshot, PendingPrompt, RunningToolCall, SessionIntentSnapshot, SessionIntentTarget, SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 78d1eeabf5..9cbcfff1e1 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -127,6 +127,17 @@ export type ConversationNode = | ToolResultNode | UnknownSurfaceNode +/** + * One `run_code` sub-dispatch materialized as a {@link ToolResultNode} so every + * consumer (tool rows, details panel) renders it through the exact components + * that render a native settled call. Never part of the surface `nodes` flow — + * sub-calls live under their parent via {@link ConversationSnapshot.codeDispatches}. + * `callId` is the deterministic sub-call id (`:code:`); `call` + * carries the sub-tool name and its JSON-stringified logged arguments; + * `content`/`isError` are the sub-call's complete logged outcome. + */ +export type CodeSubCall = ToolResultNode + /** In-flight tool card material: tool/call seen, tool/result not yet. */ export interface RunningToolCall { callId: string @@ -212,6 +223,13 @@ export interface ConversationSnapshot { foldDegraded: boolean partial: PartialAssistant | null runningCalls: readonly RunningToolCall[] + /** + * `run_code` sub-dispatches grouped under their parent callId, in dispatch + * order. Populated from in-window `tool/code-dispatch` events (live and + * replay identically); the per-parent array reference is stable across + * unrelated snapshot swaps (memo premise, same regime as `nodes`). + */ + codeDispatches: ReadonlyMap pending: readonly PendingInteraction[] running: boolean /** Input-area shape (see {@link ComposerPhase}); derived here, switched on by consumers. */ diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 396d0aa798..1c2eac7ea3 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -11,7 +11,7 @@ import type { import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import type { ObservableSnapshot } from '../contract/store.ts' import type { - ComposerPhase, ConversationNode, ConversationSnapshot, OpenState, PendingPrompt, + CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState, PendingPrompt, PromptError, RunningToolCall, SessionIntentSnapshot, SessionIntentTarget, } from './conversation.ts' import type { PendingInteraction } from './pending.ts' @@ -66,6 +66,11 @@ export class Session implements ObservableSnapshot { private pendingCache: { rev: number; value: PendingInteraction[] } | null = null private frozenRev = 0 private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null + /** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends + * copy-on-write the per-parent array so published snapshot references never mutate. */ + private codeDispatches = new Map() + private dispatchesRev = 0 + private dispatchesCache: { rev: number; value: ReadonlyMap } | null = null private running = false /** * Sticky send marker, private input of the composerPhase derivation: set @@ -611,6 +616,39 @@ export class Session implements ObservableSnapshot { /** Per-event side effects (right column of the §A.9 dispatch table): * chunk accumulation / partial clear on finalize / openCalls add-remove. */ private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void { + // `tool/code-dispatch` is declared by the host-side dsh-tools plugin whose + // types cannot enter the client program (its host Context merges collide + // with the client's), so this wire consumer narrows it structurally — + // the same posture as every other cross-wire event payload. + if ((event.type as string) === 'tool/code-dispatch') { + // A sub-dispatch becomes a ToolResultNode so rows and the details + // panel reuse the native rendering path verbatim; it indexes under its + // parent run_code callId and never joins the surface flow. + const data = event.data as unknown as { + parentCallId: string + subCallId: string + name: string + arguments: unknown + isError: boolean + content: ContentBlock[] + } + const parent = data.parentCallId + const siblings = this.codeDispatches.get(parent) ?? [] + const sub: CodeSubCall = { + kind: 'tool-result', seq: event.seq, time: event.time, + callId: data.subCallId, + call: { name: data.name, argsRaw: JSON.stringify(data.arguments) }, + // The settle event is the only timestamp this event carries; the + // start time is unknown (null per the ToolResultNode contract), so + // duration-aware consumers never see a fabricated zero-duration call. + callTime: null, + content: data.content, isError: data.isError, + callView: null, resultView: null, + } + this.codeDispatches.set(parent, [...siblings, sub]) + this.dispatchesRev++ + return + } switch (event.type) { case 'assistant/chunk': { const { turn, step, chunk } = event.data @@ -690,6 +728,8 @@ export class Session implements ObservableSnapshot { this.callsRev++ this.frozenNodes = [] this.frozenRev++ + this.codeDispatches = new Map() + this.dispatchesRev++ for (let i = 0; i < this.events.length; i++) { const event = this.events[i] /* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */ @@ -722,6 +762,9 @@ export class Session implements ObservableSnapshot { if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) { this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] } } + if (this.dispatchesCache === null || this.dispatchesCache.rev !== this.dispatchesRev) { + this.dispatchesCache = { rev: this.dispatchesRev, value: new Map(this.codeDispatches) } + } const partial = this.partial?.toPartial() ?? null return { sessionId: this.sessionId, @@ -730,6 +773,7 @@ export class Session implements ObservableSnapshot { partial, runningCalls: this.callsCache.value, pending: this.pendingCache.value, + codeDispatches: this.dispatchesCache.value, running: this.running, composerPhase: derivePhase( nodes.length > 0 || partial !== null || this.running || this.pendingCache.value.length > 0, diff --git a/packages/client/runtime/tests/event-script.ts b/packages/client/runtime/tests/event-script.ts index b567800c9b..3baeea2598 100644 --- a/packages/client/runtime/tests/event-script.ts +++ b/packages/client/runtime/tests/event-script.ts @@ -26,6 +26,11 @@ export const ev = { at(seq, { type: 'tool/call', data: { turn, step, callId, name, arguments: args } }), toolResult: (seq: number, turn: number, callId: string, body: string, step = 0): SessionEvent => at(seq, { type: 'tool/result', surfaceOp: 'append', data: { turn, step, callId, content: text(body), isError: false } }), + codeDispatch: (seq: number, parentCallId: string, n: number, name: string, args: unknown, body: string, isError = false): SessionEvent => + at(seq, { + type: 'tool/code-dispatch', + data: { parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args, isError, content: text(body) }, + }), stepEnd: (seq: number, turn: number, step = 0): SessionEvent => at(seq, { type: 'step/end', data: { turn, step } }), turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent => diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 136709b20c..9cf153f105 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -644,6 +644,66 @@ describe('resync', () => { }) }) +describe('run_code sub-dispatch indexing', () => { + it('indexes live tool/code-dispatch events under their parent as native-shaped result nodes', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答')) + await session.open() + const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } + feed(ev.turnStart(6, 1)) + feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}')) + feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls', description: '列目录' }, 'demo.txt')) + feed(ev.codeDispatch(9, 'p1', 2, 'read', { path: 'a.txt' }, 'Error: ENOENT', true)) + const subs = session.getSnapshot().codeDispatches.get('p1') + expect(subs).toHaveLength(2) + expect(subs?.[0]).toMatchObject({ + kind: 'tool-result', callId: 'p1:code:1', + call: { name: 'bash', argsRaw: '{"command":"ls","description":"列目录"}' }, + // The settle event carries no start time: callTime stays null (never a + // fabricated zero-duration). + callTime: null, + isError: false, content: [{ type: 'text', text: 'demo.txt' }], + }) + expect(subs?.[1]).toMatchObject({ callId: 'p1:code:2', isError: true }) + // Sub-dispatches never join the surface flow. + expect(session.getSnapshot().nodes.some(n => n.kind === 'tool-result' && n.callId.includes(':code:'))).toBe(false) + }) + + it('rebuilds the same index from a history window (replay parity)', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse([ + ...plainTurn(0, 0, '问', '答'), + ev.turnStart(6, 1), + ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}'), + ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls' }, 'demo.txt'), + ev.toolResult(9, 1, 'p1', '{"done":true}'), + ev.turnEnd(10, 1), + ]) + await session.open() + const subs = session.getSnapshot().codeDispatches.get('p1') + expect(subs).toHaveLength(1) + expect(subs?.[0]).toMatchObject({ callId: 'p1:code:1', call: { name: 'bash' } }) + }) + + it('keeps the dispatch map reference across unrelated changes and swaps it on a new dispatch', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, '稳', '定')) + await session.open() + const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } + feed(ev.turnStart(6, 1)) + feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}')) + feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls' }, 'x')) + const before = session.getSnapshot() + feed(ev.chunkStart(9, 1)) + feed(ev.chunkText(10, 1, '流式')) + const after = session.getSnapshot() + expect(after.codeDispatches).toBe(before.codeDispatches) + feed(ev.codeDispatch(11, 'p1', 2, 'read', { path: 'a' }, 'y')) + expect(session.getSnapshot().codeDispatches).not.toBe(after.codeDispatches) + expect(session.getSnapshot().codeDispatches.get('p1')).toHaveLength(2) + }) +}) + describe('reference stability (the memo contract)', () => { it('keeps unchanged node references across an append and swaps the snapshot object', async () => { const { api, session } = makeSession() diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 505ea7275e..8117d42ea4 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: f5e2603e8ad588740bea3be4f1a195d658721f0c -README.zh.md: 900d3d1608da3c86078dc56ec819b1f851e63de7 +README.md: b9ec555f158722ea1f41e01c4b3f7131d3fe3467 +README.zh.md: b1e3c1f4331148ebf1c58b4bcd4869270bb44311 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index f5e2603e8a..b9ec555f15 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -8,7 +8,7 @@ The no-session hero renders the frontend Session Intent from the Session list pr The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. -Generic tool rows classify the built-in bash, read, search, write, and edit names into dedicated visual variants. The filesystem variants render the edit icon and `Write · ` or `Edit · ` summary while retaining the shared row-to-details interaction. +Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and `Write · ` or `Edit · ` summary while retaining the shared row-to-details interaction. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged), and the details panel resolves a selected sub-call id to its full logged args and complete output. Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 900d3d1608..b1e3c1f433 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -8,7 +8,7 @@ 视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: `);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。 -通用工具行把内置的 bash、read、search、write 和 edit 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · ` 或 `Edit · ` 摘要,同时保留共享的行到详情交互。 +通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · ` 或 `Edit · ` 摘要,同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行),details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index 2c3d700501..d548f2d7be 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -45,6 +45,18 @@ outline-offset: 1px; } +/* run_code sub-dispatch rows: indented under the parent row, left-edged so + the code turn reads as one unit; each nested row is itself a .callRow + (same components, same selection outline as top-level rows). */ +.subCalls { + display: flex; + flex-direction: column; + gap: 4px; + margin: 4px 0 2px 22px; + padding-left: 8px; + border-left: 1px solid var(--dsw-alias-border-l2); +} + .hint { color: var(--dsw-alias-label-tertiary); font-size: 12px; diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 8023acddde..6d4c17d305 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -45,10 +45,35 @@ type RenderToolRow = ChatViewSlotProps['renderSlot'] * chat view narrows once to the runtime snapshot the binding actually feeds. */ type UseConversation = SnapshotSelectorHook +/** One `run_code` sub-dispatch row: the identical keyed-slot dispatch as a + * top-level call (same registrations, same fallback), nested by the parent. */ +const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, selected }: { + renderSlot: RenderToolRow + node: ToolResultNode + onOpenDetails: OpenDetails + selected: boolean +}) { + const toolName = node.call?.name ?? '' + const owner = useMemo(() => ({ + callId: node.callId, toolName, block: node, + openDetails: () => { onOpenDetails({ turnSeq: node.seq, callId: node.callId, toolName }) }, + }), [node, toolName, onOpenDetails]) + return ( +
+ {renderSlot('conversation.chat.toolview', owner, { + entryKey: toolName, + fallback: , + })} +
+ ) +}) + /** One tool call row (result or running): dispatches through the keyed * toolview slot with the owner payload; unregistered tools fall back to - * GenericToolCard at this render site. */ -const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq, onOpenDetails, selected }: { + * GenericToolCard at this render site. A `run_code` call additionally + * renders its logged sub-dispatches as always-visible indented rows — + * each one the same keyed-slot dispatch as a native top-level call. */ +const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq, onOpenDetails, selected, subCalls, selectedCallId }: { renderSlot: RenderToolRow callId: string toolName: string @@ -57,6 +82,10 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq seq: number onOpenDetails: OpenDetails selected: boolean + /** `run_code` sub-dispatches in dispatch order (reference-stable per parent); undefined for ordinary calls. */ + subCalls?: readonly ToolResultNode[] | undefined + /** The store's selected callId, matched against sub-rows (undefined when no sub-row here is selected). */ + selectedCallId?: string | undefined }) { const owner = useMemo(() => ({ callId, toolName, block, @@ -68,17 +97,32 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq entryKey: toolName, fallback: , })} + {subCalls !== undefined && subCalls.length > 0 && ( +
+ {subCalls.map((node) => ( + + ))} +
+ )} ) }) /** Consecutive tool results as one step-run group (figma VERTICAL gap10). */ -const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId }: { +const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId, codeDispatches }: { renderSlot: RenderToolRow results: readonly ToolResultNode[] onOpenDetails: OpenDetails - /** Only set when the selected call lives in THIS group (memo economy). */ + /** Only set when the selected call lives in THIS group, top-level or nested (memo economy). */ selectedCallId: string | undefined + /** Sub-dispatch index off the snapshot (map reference is chunk-storm stable). */ + codeDispatches: ReadonlyMap }) { return (
@@ -92,6 +136,8 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, seq={node.seq} onOpenDetails={onOpenDetails} selected={node.callId === selectedCallId} + subCalls={codeDispatches.get(node.callId)} + selectedCallId={selectedCallId} /> ))}
@@ -116,6 +162,7 @@ function StreamingTail({ useSession, onGrow }: { export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) { const nodes = useSession((s) => s.nodes) const runningCalls = useSession((s) => s.runningCalls) + const codeDispatches = useSession((s) => s.codeDispatches) const pending = useSession((s) => s.pending) const openState = useSession((s) => s.openState) const openErrorMessage = useSession((s) => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`) @@ -203,7 +250,8 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl const renderItem = (item: ChatFlowItem): ReactNode => { if (item.kind === 'tool-group') { const inGroup = selectedCallId !== undefined - && item.results.some((r) => r.callId === selectedCallId) + && item.results.some((r) => r.callId === selectedCallId + || codeDispatches.get(r.callId)?.some((sub) => sub.callId === selectedCallId) === true) return ( ) } @@ -250,6 +299,8 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl seq={call.turn} onOpenDetails={openDetails} selected={call.callId === selectedCallId} + subCalls={codeDispatches.get(call.callId)} + selectedCallId={selectedCallId} /> ))} diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx index 9b507e0662..7dbefdc139 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx @@ -6,7 +6,7 @@ import type { ReactNode } from 'react' import { - IconApiOutline14, IconBrowseOutline16, IconEditOutline16, IconSearchOutline16, IconThinkOutline14, + IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconThinkOutline14, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolRowOwnerProps } from '../contract/slots.ts' import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts' @@ -21,6 +21,7 @@ const VARIANT_ICONS: Record = { bash: , write: , edit: , + code: , others: , } diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css index c82c9cce20..204af4573d 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css @@ -86,3 +86,15 @@ button.leading { word-break: break-word; color: var(--dsw-alias-label-tertiary); } + +/* The code variant's expanded body is the run_code program: monospace on the + markdown code-block fill so the program reads as code, not prose. */ +.root[data-variant='code'] .body { + font-family: var(--ds-font-family-code); + font-size: 13px; + line-height: 20px; + padding: 6px 8px; + margin-left: 22px; + border-radius: 6px; + background: var(--dsw-alias-markdown-code-block); +} diff --git a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts index 1072b0cbbb..1c5996580f 100644 --- a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts +++ b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts @@ -13,8 +13,8 @@ export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' /** The frozen slice the chat view hands to toolview components as `block` * (both members are cache-stable references off ConversationSnapshot). */ -/** The seven row variants (think is fed by reasoning blocks, not tool calls). */ -export type ToolRowVariant = 'think' | 'search' | 'read' | 'bash' | 'write' | 'edit' | 'others' +/** The eight row variants (think is fed by reasoning blocks, not tool calls). */ +export type ToolRowVariant = 'think' | 'search' | 'read' | 'bash' | 'write' | 'edit' | 'code' | 'others' /** Row state semantic; colors self-supplied via StateDot (design gives none). */ export type ToolRowState = 'running' | 'ok' | 'error' | 'stopped' @@ -22,7 +22,7 @@ export type ToolRowState = 'running' | 'ok' | 'error' | 'stopped' /** Figma row titles per variant (design literals, not translatable copy). */ export const VARIANT_TITLES: Record = { think: 'Think', search: 'Search', read: 'Read', bash: 'Bash', - write: 'Write', edit: 'Edit', others: 'Tool call', + write: 'Write', edit: 'Edit', code: 'Code', others: 'Tool call', } /** Known tool name -> variant. */ @@ -35,6 +35,7 @@ const TOOL_VARIANTS: Record = { glob: 'search', write: 'write', edit: 'edit', + run_code: 'code', } /** @@ -86,6 +87,7 @@ const SUMMARY_KEYS: Record = { think: [], write: ['path', 'file_path'], edit: ['path', 'file_path'], + code: ['description'], others: [], } @@ -101,10 +103,17 @@ function deriveSummary(variant: ToolRowVariant, argsRaw: string): string { return firstLine(argsRaw) } -function deriveBody(argsRaw: string): string | null { +function deriveBody(variant: ToolRowVariant, argsRaw: string): string | null { if (argsRaw === '') return null const parsed = parseArgs(argsRaw) - return parsed === undefined ? argsRaw : JSON.stringify(parsed, null, 2) + if (parsed === undefined) return argsRaw + // The code row's expanded body IS the program (monospace via the row's + // variant styling), not the args JSON envelope around it. + if (variant === 'code' && typeof parsed === 'object' && parsed !== null) { + const code = (parsed as Record).code + if (typeof code === 'string' && code !== '') return code + } + return JSON.stringify(parsed, null, 2) } /** @@ -128,7 +137,7 @@ export function toolRowModel(toolName: string, block: ToolCallBlock): ToolRowMod variant, title: VARIANT_TITLES[variant], summary, - body: deriveBody(argsRaw), + body: deriveBody(variant, argsRaw), state, } } diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx index 54cb0bfb97..b726931990 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx @@ -31,6 +31,15 @@ function materialFor(s: ConversationSnapshot, callId: string): CallMaterial | nu if (open !== undefined) { return { name: open.name, argsRaw: open.argsRaw, result: null, running: true } } + // run_code sub-dispatches: already ToolResultNode-shaped, so a selected + // sub-row resolves through the same material as a native settled call. + for (const subs of s.codeDispatches.values()) { + for (const sub of subs) { + if (sub.callId === callId) { + return { name: sub.call?.name ?? callId, argsRaw: sub.call?.argsRaw ?? null, result: sub, running: false } + } + } + } return null } diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx new file mode 100644 index 0000000000..88d03eef6e --- /dev/null +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -0,0 +1,214 @@ +// @vitest-environment jsdom +// Code Mode sub-call acceptance on the REAL machinery stack (same bench as +// chat-toolview-slot.spec): a run_code result renders the 'code' variant row +// (description summary, program body), its logged sub-dispatches render as +// always-visible nested rows through the SAME keyed toolview hole — the bash +// sub-call lands in the bash sample plugin's registration exactly like a +// top-level bash row, unregistered sub-tools fall back to GenericToolCard — +// and a sub-row click opens details for the sub-callId. Running parents +// (runningCalls) nest their so-far dispatches the same way. + +import { Context } from 'cordis' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import type { + CodeSubCall, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, + ToolResultNode, WorkspaceListState, +} from '@deepseek-ai/dsh-client-runtime/client' +import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client' + +const SID = 's1' as SessionId + +afterEach(cleanup) +beforeEach(() => { + localStorage.clear() +}) + +const PROGRAM = 'const listing = await tools.bash({ command: "ls notes", description: "List notes" })\nreturn listing' +const RUN_CODE_ARGS = JSON.stringify({ code: PROGRAM, description: 'List the notes directory' }) + +const codeResult = (seq: number, callId: string): ToolResultNode => ({ + kind: 'tool-result', seq, time: seq * 1_000, callId, + call: { name: 'run_code', argsRaw: RUN_CODE_ARGS }, + callTime: seq * 1_000 - 500, + content: [{ type: 'text', text: 'demo.txt' }], isError: false, callView: null, resultView: null, +}) + +const runningCode = (callId: string): RunningToolCall => ({ + callId, name: 'run_code', argsRaw: RUN_CODE_ARGS, turn: 9, step: 0, time: 9_000, callView: null, +}) + +const subCall = (seq: number, parent: string, n: number, name: string, args: object, resultText: string, isError = false): CodeSubCall => ({ + kind: 'tool-result', seq, time: seq * 1_000, + callId: `${parent}:code:${n}`, + call: { name, argsRaw: JSON.stringify(args) }, + callTime: seq * 1_000, + content: [{ type: 'text', text: resultText }], isError, callView: null, resultView: null, +}) + +function snapshotWith( + nodes: ToolResultNode[], + codeDispatches: ReadonlyMap, + runningCalls: RunningToolCall[] = [], +): ConversationSnapshot { + return { + sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls, codeDispatches, + pending: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false, + openState: 'open', openError: null, + hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null, + } as ConversationSnapshot +} + +/** Test-owned AppFrame role: declares the layout-owned children and renders the conversation area under the framework session provider. */ +type AppRootProps = PropsRenderSlots<'conversation' | 'details' | 'conversation.empty'> +function AppRoot({ renderSlot, SessionProvider }: AppRootProps) { + return {() => renderSlot('conversation', {})} +} + +/** Same real-stack bench as the toolview-slot spec: SlotsService + renderer + this package's apply; fakes only at service seams. */ +async function bench(snapshot: ConversationSnapshot) { + const ctx = new Context() + const slotsFiber = ctx.plugin(SlotsService) + await slotsFiber.await() + const slots = ctx.get('slots') as SlotsService + + const session = createSnapshotStore(snapshot) + const list = createSnapshotStore({ + ids: [SID], + byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, updatedAt: 1 } }, + current: SID, + intent: undefined, + phase: 'ready', + }) + const cell = { sessionId: SID, session } + const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) } + const layout = { openDetails: vi.fn(), closeDetails: vi.fn() } + ctx.provide('sessions', { + list, + binding: (id: SessionId) => ({ sessionId: id, session: { loadOlder: vi.fn() } }), + scope: () => ({ get: () => scoped }), + cell: (id: string) => (id === SID ? cell : undefined), + create: vi.fn(), + open: vi.fn(), + updateIntent: vi.fn(), + }) + ctx.provide('workspaces', { + list: createSnapshotStore({ + items: [], intent: undefined, state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + }), + startSession: vi.fn(), + sendSession: vi.fn(), + }) + ctx.provide('layout', layout) + ctx.provide('i18n', { bind: () => (key: string) => key }) + + slots.install(createSlotRenderer()) + slots.register({ + name: 'root', + children: { + 'conversation': { kind: 'single', scope: 'session' }, + 'details': { kind: 'single', scope: 'session' }, + 'conversation.empty': { kind: 'single', scope: 'root' }, + }, + }, AppRoot) + + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + return { ctx, slots, fiber, session, layout } +} + +function mountApp(slots: SlotsService) { + return render(<>{slots.renderSlot('root', {})}) +} + +describe('run_code sub-calls through the real chat machinery', () => { + it('renders the code-variant parent row with the description summary and nested sub-rows', async () => { + const parent = 'call-64' + const dispatches = new Map([[parent, [ + subCall(11, parent, 1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'), + subCall(12, parent, 2, 'mystery', { n: 1 }, 'ok'), + ]]]) + const b = await bench(snapshotWith([codeResult(10, parent)], dispatches)) + const view = mountApp(b.slots) + + // Parent row: the code variant with the model-authored description. + const codeRoot = view.container.querySelector('[data-variant="code"]') + expect(codeRoot).not.toBeNull() + expect(view.getByText('Code')).toBeTruthy() + expect(view.getByText('List the notes directory')).toBeTruthy() + + // Nested rows are ALWAYS visible (no parent expand needed): the bash + // sub-call landed in the bash sample plugin's keyed registration — the + // exact component a native top-level bash row uses — and the unregistered + // sub-tool fell back to GenericToolCard at the same render site. + const nest = view.container.querySelector('[data-subcalls]') + expect(nest).not.toBeNull() + expect(nest!.querySelector('[data-sample="bash-global"]')).not.toBeNull() + expect(view.getByText('List notes')).toBeTruthy() + expect(view.getByText('Tool call')).toBeTruthy() + }) + + it('expanding the code row reveals the program body verbatim', async () => { + const parent = 'call-64' + const b = await bench(snapshotWith([codeResult(10, parent)], new Map())) + const view = mountApp(b.slots) + // The code row is expandable via its leading control (body = the program). + const toggle = view.container.querySelector('[data-variant="code"] button[aria-expanded]') + expect(toggle).not.toBeNull() + fireEvent.click(toggle!) + expect(view.getByText(/const listing = await tools\.bash/)).toBeTruthy() + }) + + it('an isError sub-call renders the error state dot exactly like a failed native row', async () => { + const parent = 'call-64' + const dispatches = new Map([[parent, [ + subCall(11, parent, 1, 'mystery', { n: 1 }, 'Error: boom', true), + ]]]) + const b = await bench(snapshotWith([codeResult(10, parent)], dispatches)) + const view = mountApp(b.slots) + const nested = view.container.querySelector('[data-subcalls] [data-variant][data-state="error"]') + expect(nested).not.toBeNull() + }) + + it('a sub-row click opens details for the sub-callId', async () => { + const parent = 'call-64' + const dispatches = new Map([[parent, [ + subCall(11, parent, 1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'), + ]]]) + const b = await bench(snapshotWith([codeResult(10, parent)], dispatches)) + const view = mountApp(b.slots) + view.getByText('List notes').click() + expect(b.layout.openDetails).toHaveBeenCalledTimes(1) + }) + + it('a RUNNING run_code call nests its so-far dispatches under the spinner row', async () => { + const parent = 'call-live' + const dispatches = new Map([[parent, [ + subCall(21, parent, 1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'), + ]]]) + const b = await bench(snapshotWith([], dispatches, [runningCode(parent)])) + const view = mountApp(b.slots) + const running = view.container.querySelector('[data-variant="code"][data-state="running"]') + expect(running).not.toBeNull() + const nest = view.container.querySelector('[data-subcalls]') + expect(nest).not.toBeNull() + expect(nest!.querySelector('[data-sample="bash-global"]')).not.toBeNull() + }) + + it('an ordinary tool row renders no sub-call nest', async () => { + const parent = 'call-64' + const plain: ToolResultNode = { + kind: 'tool-result', seq: 10, time: 10_000, callId: parent, + call: { name: 'mystery', argsRaw: '{"n":1}' }, + callTime: 9_500, + content: [], isError: false, callView: null, resultView: null, + } + const b = await bench(snapshotWith([plain], new Map())) + const view = mountApp(b.slots) + expect(view.container.querySelector('[data-subcalls]')).toBeNull() + }) +}) diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx index d73f8243ae..3d56970846 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx @@ -26,7 +26,7 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage function snapshotBase(): ConversationSnapshot { return { - sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], + sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null, } diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 959ee871d8..b636a227e2 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -39,7 +39,7 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot { return { - sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [], + sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null, } as ConversationSnapshot diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index bb55fcac77..e96634ed98 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -28,7 +28,7 @@ const SID = 's1' as SessionId function snapshotBase(): ConversationSnapshot { return { - sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], + sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null, } diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx index 7994930b75..d8e0bfbe6f 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -18,7 +18,7 @@ const SID = 's1' as SessionId function snapshotBase(): ConversationSnapshot { return { - sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], + sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null, } as ConversationSnapshot @@ -84,4 +84,40 @@ describe('render branch tails', () => { expect(view.getByText('详情')).toBeTruthy() expect(view.getByText('该调用不在当前窗口内')).toBeTruthy() }) + + it('DetailsPanel resolves a run_code sub-callId to its full logged args and output', () => { + localStorage.clear() + const snap = snapshotBase() + const longText = 'x'.repeat(1_000) + snap.codeDispatches = new Map([['p1', [{ + kind: 'tool-result', seq: 8, time: 8_000, callId: 'p1:code:1', + call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' }, + callTime: 8_000, + content: [{ type: 'text', text: longText }], isError: false, callView: null, resultView: null, + }]]]) + const chat = createChatStore().create() + chat.actions.select({ turnSeq: 8, callId: 'p1:code:1', toolName: 'read' } satisfies SelectionTarget) + const emptyList = createSnapshotStore( + { ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' }) + const emptyWorkspaces = createSnapshotStore({ + items: [], intent: undefined, state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + }) + const view = render( + snap, subscribe: () => () => {} }) as unknown as UseSession} + useSessions={bindSnapshotSelector(emptyList)} + useWorkspaces={bindSnapshotSelector(emptyWorkspaces)} + useStore={bindSnapshotSelector(chat)} + actions={chat.actions} + closeDetails={vi.fn()} + />, + ) + // Sub-call material: the sub-tool name titles the panel, args pretty-print, + // and the COMPLETE logged output renders (no truncation anywhere). + expect(view.getByText('read')).toBeTruthy() + expect(view.getByText(/notes\/demo\.txt/)).toBeTruthy() + expect(view.getByText(longText)).toBeTruthy() + }) }) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index e4a8aa2696..535f4f42f4 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -119,7 +119,7 @@ function conversationSnapshot( pendingPrompt: ConversationSnapshot['pendingPrompt'] = null, ): ConversationSnapshot { return { - sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], + sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], running: false, composerPhase, removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt, lastAgentError: null, } diff --git a/tsconfig.host.json b/tsconfig.host.json index 72b7f245c3..85f595e01b 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -19,6 +19,7 @@ "apps/web/tests/workspace-management.e2e.ts", "apps/web/tests/replay-round-trip.e2e.ts", "apps/web/tests/seeded-history.e2e.ts", + "apps/web/tests/code-mode-round.e2e.ts", "apps/cli/tests/**/*.ts", "examples/*/src/**/*.ts", "examples/*/start.ts",