diff --git a/apps/web/tests/code-mode-fixture.snapshot.ts b/apps/web/tests/code-mode-fixture.snapshot.ts index f53777fff8..ce5a98b2eb 100644 --- a/apps/web/tests/code-mode-fixture.snapshot.ts +++ b/apps/web/tests/code-mode-fixture.snapshot.ts @@ -5,7 +5,7 @@ // 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, details-panel resolution of a sub-callId, and +// the expanded program body, inert bash / file-link sub-row gestures, and // the trajectory/waterfall tabs' sub-call cells and timing lanes. import { readFileSync } from 'node:fs' import { join } from 'node:path' @@ -152,7 +152,7 @@ it('renders the fixture run_code turn: code parent row, nested sub-rows, error s `) }) -it('expands the code row into the program body and resolves a sub-row through the details panel', async () => { +it('expands the code row into the program body; sub-row clicks do not open details', async () => { boot() await openFixtureSession() @@ -171,26 +171,28 @@ it('expands the code row into the program body and resolves a sub-row through th } }) - // Sub-row click → details panel resolves the sub-callId with FULL output. + // Tool rows no longer drive the details panel: bash is inert, file paths + // are host-open links (fixture openPath is a no-op success). 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') + const fileLink = nest.querySelector('button') + if (fileLink === null) throw new Error('file-path summary link missing on a read sub-row') + const frame = document.querySelector('[data-details-collapsed]') + if (frame === null) throw new Error('app frame missing') + expect(frame.getAttribute('data-details-collapsed')).toBe('true') 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(frame.getAttribute('data-details-collapsed')).toBe('true') + fireEvent.click(fileLink) + expect(frame.getAttribute('data-details-collapsed')).toBe('true') 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'), + fileLink: visibleText(fileLink), + detailsCollapsed: frame.getAttribute('data-details-collapsed'), }).toMatchInlineSnapshot(` { - "inputEchoesArgs": true, - "outputComplete": true, - "title": "bash", + "detailsCollapsed": "true", + "fileLink": "notes/demo.txt", } `) }) diff --git a/apps/web/tests/code-mode-round.e2e.ts b/apps/web/tests/code-mode-round.e2e.ts index 32c51a2a2a..a7f493e448 100644 --- a/apps/web/tests/code-mode-round.e2e.ts +++ b/apps/web/tests/code-mode-round.e2e.ts @@ -118,19 +118,14 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => { 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 () => { + it.skipIf(MODE === 'record')('a bash sub-row click leaves the details panel collapsed', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-details')) const nest = page.locator('[data-subcalls]').first() + const frame = page.locator('[data-details-collapsed], [class*="frame"]').first() + expect(await frame.getAttribute('data-details-collapsed')).not.toBeNull() 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) + // Tool rows no longer open details; the column stays width 0. + await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull() }) it.skipIf(MODE === 'record')('matches the conversation aria golden with stable anchors', async () => { diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index bbae7363df..a27a61f80f 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -1,12 +1,11 @@ // Web e2e scenarios: navigation & panes — the view tabs (Trajectory / -// Waterfall), the details column, and sidebar search, all over ONE rich -// two-turn seeded fixture rendered purely from the log (the seeded-history -// pattern: zero model calls in replay, so every surface here is the client -// fold + host history RPC, not replay binding). The seed is recorded live -// under the standard discipline: turn 1 produces a bash call plus two -// parallel reads in one assistant message (tool-call density for the -// trajectory/waterfall lanes and a details-capable bash row), turn 2 a -// markdown-rich reply (a second turn so the waterfall has two lanes). +// Waterfall) and sidebar search, all over ONE rich two-turn seeded fixture +// rendered purely from the log (the seeded-history pattern: zero model calls +// in replay, so every surface here is the client fold + host history RPC, +// not replay binding). The seed is recorded live under the standard +// discipline: turn 1 produces a bash call plus two parallel reads in one +// assistant message (tool-call density for the trajectory/waterfall lanes), +// turn 2 a markdown-rich reply (a second turn so the waterfall has two lanes). import { mkdir, readFile, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' @@ -25,7 +24,6 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/navigation-panes', impor const SEED = join(SNAPSHOT_DIR, 'seed.jsonl') const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md') const WATERFALL_EXPECTED = join(SNAPSHOT_DIR, 'waterfall.expected.md') -const DETAILS_EXPECTED = join(SNAPSHOT_DIR, 'details-open.expected.md') const MODE = webSnapshotMode() const SEED_ID = 'navigation-panes-web-e2e' @@ -155,36 +153,27 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await compareOrRefreshGolden(WATERFALL_EXPECTED, snapshot, MODE) }, 60_000) - it.skipIf(MODE === 'record')('opens the details column from the bash row and closes it', async () => { + it.skipIf(MODE === 'record')('bash and file-path rows leave the details column collapsed', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details')) await page.getByRole('tab', { name: 'Chat' }).click() - // The bash toolview row routes its click to openDetails (read rows are - // expand-in-place instead — the seeded-history scenario owns that fold). const bashRow = page.locator('[data-sample="bash-global"]').first() await bashRow.waitFor({ timeout: 15_000 }) - // Open/closed is the frame's collapsed attribute: the column collapses to - // width 0 but its subtree deliberately never unmounts (hidden, not - // absent), so element presence/visibility cannot express the state. const frame = page.locator('[data-details-collapsed], [class*="frame"]').first() expect(await frame.getAttribute('data-details-collapsed')).not.toBeNull() await bashRow.click() - await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 10_000 }).toBeNull() - // The open panel shows the selected call's name, arguments, and durable - // result (NAVIGATION_OK appears in the chat row too, hence >= 2 total). - await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2) - // Golden of the open panel: tool name header, Input args, Output result. - const snapshot = (await captureStableAria(page, '[class*="detailsCol"]', scaffold.workspaceCwd)) - .split(SEED_ID).join('{{seededId}}') - await compareOrRefreshGolden(DETAILS_EXPECTED, snapshot, MODE) - await page.getByRole('button', { name: '关闭详情' }).click() - await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 10_000 }).not.toBeNull() + await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull() + // Read summaries are host-open file links; they also must not open details. + const fileLink = page.locator('[data-variant="read"] button').first() + await fileLink.waitFor({ timeout: 10_000 }) + await fileLink.click() + await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull() }, 60_000) it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ - 'seed.jsonl', 'trajectory.expected.md', 'waterfall.expected.md', 'details-open.expected.md', + 'seed.jsonl', 'trajectory.expected.md', 'waterfall.expected.md', ]) }) }) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 265bb20d07..0742612e29 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -103,21 +103,19 @@ describe('web e2e: seeded history renders through cold resume', () => { await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) }) - it.skipIf(MODE === 'record')('expands and collapses a tool row rebuilt from the cold log', async () => { + it.skipIf(MODE === 'record')('file-path tool rows rebuilt from the cold log stay details-inert', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-toolrow')) - // Interaction over cold-resumed history: read rows are expand-in-place - // rows (rowExpands routes the click to toggleExpand, not openDetails), so - // the gesture under test is the inline fold over log-rebuilt content. - // Runs after the golden capture; still zero model calls. - const row = page.locator('[data-variant] [data-clickable][role="button"]').first() - await row.waitFor({ timeout: 10_000 }) - expect(await row.getAttribute('aria-expanded')).toBe('false') - await row.click() - await expect.poll(() => row.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true') - // The expanded body renders the recorded tool result (a.txt's contents). - await expect.poll(() => page.getByText('alpha', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0) - await row.click() - await expect.poll(() => row.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false') + // Interaction over cold-resumed history: read summaries are host-open + // file links (not expand-in-place / not details). Runs after the golden + // capture; still zero model calls. + const fileLink = page.locator('[data-variant="read"] button').first() + await fileLink.waitFor({ timeout: 10_000 }) + const frame = page.locator('[data-details-collapsed], [class*="frame"]').first() + expect(await frame.getAttribute('data-details-collapsed')).not.toBeNull() + await fileLink.click() + await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull() + // Path label survives from the recorded args (a.txt). + await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0) }) it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => { diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 58db1beafd..47d6ea81f7 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -7,7 +7,7 @@ // // Selector convention: CSS Modules hash as [hash]_[local], so class-substring // selectors are unreliable — anchor on data-* attributes (data-variant / -// data-clickable / data-sample) or visible text. The one [class*=] use below +// data-sample) or visible text. The one [class*=] use below // (frame/handle) rides local names that survive hashing as suffixes; prefer // data-* for anything new. // @@ -448,7 +448,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke await screen(page, '07-back-to-chat') }) - it('5 bash differential rendering: tool row click opens the details column', async () => { + it('5 bash differential rendering: tool row click leaves the details column collapsed', async () => { onTestFailed(() => saveFailureShot(page, 'w5-tool-details')) const input = page.locator('textarea').first() await input.fill('请用 bash 工具运行命令 echo w5marker 然后告诉我结果') @@ -462,13 +462,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke await screen(page, '08-bash-round') expect(await detailsTrack(page)).toBe(0) await toolRow.click() - // Selection channel: click writes selection + layout.openDetails. - 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 screen(page, '09-details-open') + // Tool rows no longer drive layout.openDetails; the column stays closed. + expect(await detailsTrack(page)).toBe(0) + await screen(page, '09-details-closed') }, 150_000) it('6 sidebar drag widens the column and persists across reload', async () => { diff --git a/apps/web/tests/snapshots/navigation-panes/details-open.expected.md b/apps/web/tests/snapshots/navigation-panes/details-open.expected.md deleted file mode 100644 index d69a95eb2d..0000000000 --- a/apps/web/tests/snapshots/navigation-panes/details-open.expected.md +++ /dev/null @@ -1,5 +0,0 @@ -- text: bash -- button "关闭详情" -- text: Input -- code: "{ \"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\" }" -- text: Output NAVIGATION_OK diff --git a/packages/host/apiproxy/src/native-command.ts b/packages/host/apiproxy/src/native-command.ts new file mode 100644 index 0000000000..0efe9679d8 --- /dev/null +++ b/packages/host/apiproxy/src/native-command.ts @@ -0,0 +1,38 @@ +/** Shared no-shell `execFile` runner for native host dialogs and openers. */ + +import { execFile } from 'node:child_process' + +/** Testable command boundary; native implementations never invoke a shell. */ +export type NativeCommandRunner = ( + command: string, + args: readonly string[], + signal: AbortSignal, +) => Promise<{ stdout: string; stderr: string }> + +/** + * Run a host command with utf8 stdio, abort propagation, and Windows hide. + * @param command - executable path or PATH name. + * @param args - argv (never a shell string). + * @param signal - caller/connection lifetime; abort terminates the child. + * @returns captured stdout/stderr on exit 0. + */ +export const runNativeCommand: NativeCommandRunner = (command, args, signal) => + new Promise((resolve, reject) => { + execFile( + command, + [...args], + { encoding: 'utf8', signal, windowsHide: true }, + (error, stdout, stderr) => { + if (error !== null) { + const failure = Object.assign(new Error(error.message, { cause: error }), { + code: error.code, + stdout, + stderr, + }) + reject(failure) + return + } + resolve({ stdout, stderr }) + }, + ) + }) diff --git a/packages/host/apiproxy/src/native-directory-picker.ts b/packages/host/apiproxy/src/native-directory-picker.ts index 8bc0bc99ce..0ddba8e30d 100644 --- a/packages/host/apiproxy/src/native-directory-picker.ts +++ b/packages/host/apiproxy/src/native-directory-picker.ts @@ -1,13 +1,9 @@ /** Cross-platform native single-directory picker used by the local GUI carrier. */ -import { execFile } from 'node:child_process' +import { runNativeCommand, type NativeCommandRunner } from './native-command.ts' /** Testable command boundary; native implementations never invoke a shell. */ -export type DirectoryPickerRunner = ( - command: string, - args: readonly string[], - signal: AbortSignal, -) => Promise<{ stdout: string; stderr: string }> +export type DirectoryPickerRunner = NativeCommandRunner /** Injectable platform facts for deterministic adapter tests. */ export interface DirectoryPickerInternals { @@ -15,27 +11,6 @@ export interface DirectoryPickerInternals { run?: DirectoryPickerRunner } -const runCommand: DirectoryPickerRunner = (command, args, signal) => - new Promise((resolve, reject) => { - execFile( - command, - [...args], - { encoding: 'utf8', signal, windowsHide: true }, - (error, stdout, stderr) => { - if (error !== null) { - const failure = Object.assign(new Error(error.message, { cause: error }), { - code: error.code, - stdout, - stderr, - }) - reject(failure) - return - } - resolve({ stdout, stderr }) - }, - ) - }) - function outputPath(stdout: string): string | null { const path = stdout.replace(/[\r\n]+$/, '') return path === '' ? null : path @@ -72,7 +47,7 @@ export async function pickNativeDirectory( internals: DirectoryPickerInternals = {}, ): Promise { const platform = internals.platform ?? process.platform - const run = internals.run ?? runCommand + const run = internals.run ?? runNativeCommand if (platform === 'darwin') { try { diff --git a/packages/host/apiproxy/src/native-path-opener.ts b/packages/host/apiproxy/src/native-path-opener.ts index 24ee444a07..15a6d7a73b 100644 --- a/packages/host/apiproxy/src/native-path-opener.ts +++ b/packages/host/apiproxy/src/native-path-opener.ts @@ -1,13 +1,9 @@ /** Cross-platform open-with-default-application used by the local GUI carrier. */ -import { execFile } from 'node:child_process' +import { runNativeCommand, type NativeCommandRunner } from './native-command.ts' /** Testable command boundary; native implementations never invoke a shell. */ -export type PathOpenerRunner = ( - command: string, - args: readonly string[], - signal: AbortSignal, -) => Promise<{ stdout: string; stderr: string }> +export type PathOpenerRunner = NativeCommandRunner /** Injectable platform facts for deterministic adapter tests. */ export interface PathOpenerInternals { @@ -15,27 +11,6 @@ export interface PathOpenerInternals { run?: PathOpenerRunner } -const runCommand: PathOpenerRunner = (command, args, signal) => - new Promise((resolve, reject) => { - execFile( - command, - [...args], - { encoding: 'utf8', signal, windowsHide: true }, - (error, stdout, stderr) => { - if (error !== null) { - const failure = Object.assign(new Error(error.message, { cause: error }), { - code: error.code, - stdout, - stderr, - }) - reject(failure) - return - } - resolve({ stdout, stderr }) - }, - ) - }) - /** PowerShell single-quoted literal (doubles embedded quotes). */ function powershellLiteral(path: string): string { return `'${path.replace(/'/g, "''")}'` @@ -53,7 +28,7 @@ export async function openNativePath( internals: PathOpenerInternals = {}, ): Promise { const platform = internals.platform ?? process.platform - const run = internals.run ?? runCommand + const run = internals.run ?? runNativeCommand if (platform === 'darwin') { await run('open', [path], signal) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index b1f86d3b79..c67eeb9fc5 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -208,6 +208,18 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect(response.result).toEqual({ ok: true, value: { path: '/tmp/project' } }) }) + it('round-trips host.openPath through the wire form', async () => { + const api = fakeApi() + let opened: string | undefined + api.host.openPath = async (request) => { + opened = request.payload.path + return { rpcId: request.rpcId, result: { ok: true, value: { opened: true as const } } } + } + const response = await client(api).host.openPath({ path: '/tmp/a.txt' }) + expect(opened).toBe('/tmp/a.txt') + expect(response.result).toEqual({ ok: true, value: { opened: true } }) + }) + it('round-trips command.list / command.execute / skill.list through the wire form', async () => { const c = client() const list = await c.commands.list({ sessionId: 's' as never }) diff --git a/packages/host/apiproxy/tests/native-path-opener.spec.ts b/packages/host/apiproxy/tests/native-path-opener.spec.ts index c3612921a1..c8622002e2 100644 --- a/packages/host/apiproxy/tests/native-path-opener.spec.ts +++ b/packages/host/apiproxy/tests/native-path-opener.spec.ts @@ -47,7 +47,18 @@ describe('native path opener', () => { .rejects.toThrow('unsupported on freebsd') }) - it('runs the default command adapter without a shell', async () => { + it('uses the current process platform when no platform override is supplied', async () => { + const run = vi.fn(async () => ({ stdout: '', stderr: '' })) + await openNativePath('/tmp/platform-default.txt', signal(), { run }) + const expected = process.platform === 'win32' + ? 'powershell.exe' + : process.platform === 'linux' + ? 'xdg-open' + : 'open' + expect(run.mock.calls[0]?.[0]).toBe(expected) + }) + + it('runs the default command adapter without a shell and preserves command failures', async () => { execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { callback(null, '', '') }) @@ -58,5 +69,14 @@ describe('native path opener', () => { expect(options.encoding).toBe('utf8') expect(options.windowsHide).toBe(true) expect(options.signal).toBeInstanceOf(AbortSignal) + + const commandError = Object.assign(new Error('open failed'), { code: 1 }) + execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { + callback(commandError, 'partial output', 'failure details') + }) + await expect(openNativePath('/tmp/missing.txt', signal(), { platform: 'darwin' })).rejects.toMatchObject({ + message: 'open failed', cause: commandError, code: 1, + stdout: 'partial output', stderr: 'failure details', + }) }) })