From 7c22f6f045ac8449340d8b0b2e36e2da017cb479 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:16:00 -0700 Subject: [PATCH] [CI] Consolidate tests, retire static scans, and update evidence (#27109) * [CI] Consolidate renderer test modules and retire static scans * [CI] Keep consolidated test within the existing line limit * [CI] Refresh evidence for the preserved turn-duration gate --- config/reliability-gates.jsonc | 14 +- .../scripts/ci-cache-warmup-workflow.test.mjs | 118 -- config/scripts/ci-native-toolchain.test.mjs | 19 - .../mobile-launch-contract-workflow.test.mjs | 34 - .../mobile-web-app-web-overrides.test.mjs | 131 -- config/scripts/orca-dev-bin.test.mjs | 6 - config/scripts/pr-e2e-gate-contract.test.mjs | 288 +--- .../scripts/pr-workflow-parallelism.test.mjs | 576 +------- .../release-tag-pattern-census.test.mjs | 99 +- .../ssh-hostile-hosts-workflow.test.mjs | 120 -- .../verify-packaged-plugin-resources.test.mjs | 7 - ...der-process-supervisor.integration.test.ts | 8 +- ...iveChatMessageList.live-reasoning.test.tsx | 1113 +++++++++++---- ...essageList.message-rail-windowing.test.tsx | 203 --- ...ChatMessageList.subagent-sections.test.tsx | 844 +++++++----- ...eChatMessageList.task-list-frames.test.tsx | 249 ---- .../NativeChatMessageList.test.tsx | 1151 ++++++++++------ .../NativeChatMessageList.turn-fold.test.tsx | 259 ---- ...ativeChatMessageList.turn-history.test.tsx | 268 ---- ...veChatMessageList.turn-membership.test.tsx | 1223 +++++++++++------ ...NativeChatMessageList.turn-timing.test.tsx | 362 ----- .../NativeChatToolRun.appearance.test.tsx | 695 ++++++++-- .../NativeChatToolRun.ask-row.test.tsx | 239 ---- .../NativeChatToolRun.members.test.tsx | 173 --- .../NativeChatToolRun.sentences.test.tsx | 106 -- ...tegration-card-status-localization.test.ts | 44 - .../notification-agent-status-locales.test.ts | 22 - .../i18n/smart-workspace-jira-locales.test.ts | 33 - .../i18n/worktree-visibility-locales.test.ts | 26 - src/shared/skills-cli-agent-keys.test.ts | 115 +- ...ternate-screen-fixture-script.unit.test.ts | 36 - 31 files changed, 3492 insertions(+), 5089 deletions(-) delete mode 100644 config/scripts/ci-cache-warmup-workflow.test.mjs delete mode 100644 config/scripts/mobile-launch-contract-workflow.test.mjs delete mode 100644 config/scripts/mobile-web-app-web-overrides.test.mjs delete mode 100644 config/scripts/ssh-hostile-hosts-workflow.test.mjs delete mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.message-rail-windowing.test.tsx delete mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.task-list-frames.test.tsx delete mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.turn-fold.test.tsx delete mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.turn-history.test.tsx delete mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.turn-timing.test.tsx delete mode 100644 src/renderer/src/components/native-chat/NativeChatToolRun.ask-row.test.tsx delete mode 100644 src/renderer/src/components/native-chat/NativeChatToolRun.members.test.tsx delete mode 100644 src/renderer/src/components/native-chat/NativeChatToolRun.sentences.test.tsx delete mode 100644 src/renderer/src/i18n/integration-card-status-localization.test.ts delete mode 100644 src/renderer/src/i18n/notification-agent-status-locales.test.ts delete mode 100644 src/renderer/src/i18n/smart-workspace-jira-locales.test.ts delete mode 100644 src/renderer/src/i18n/worktree-visibility-locales.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 7898bf89175..aec0fe91af2 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1616,7 +1616,7 @@ "invariant": "An observed completed turn retains the same host-recorded duration and initiating user-message attribution through client reload, settlement retry and loading older history; older clients receive the compatible item carrier, and a host-recorded unknown duration suppresses local estimates.", "oracle": "Render a host-settled duration without observing completion locally; preserve an exit at 2000 through failed settlement at 60000 and later retry; retain a seven-second older turn beyond 256 submitted turns; prune aliases with removed user items and preserve alias identity during item-only streaming; render no completed duration for an unverifiable turn both before and after client reload.", "commands": [ - "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/native-chat-unverifiable-turn-status.test.ts src/main/codex/codex-requested-close-turn-timing.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts src/shared/structured-agent-session-turn-timing-retention.test.ts src/shared/structured-agent-session-turn-timing.test.ts src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts src/renderer/src/components/native-chat/NativeChatMessageList.turn-timing.test.tsx" + "ORCA_BACKGROUND_LAUNCH=1 node config/scripts/run-vitest.mjs run --config config/vitest.config.ts src/shared/native-chat-unverifiable-turn-status.test.ts src/main/codex/codex-requested-close-turn-timing.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts src/shared/structured-agent-session-turn-timing-retention.test.ts src/shared/structured-agent-session-turn-timing.test.ts src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx --maxWorkers=4 --reporter=json --outputFile=notes/bun-migration/performance/ci-throughput-resume-20261009/next_capacity_wave_owner/reliability-path-correction/fresh-eight-root-stock-config.json" ], "testFiles": [ "src/shared/native-chat-unverifiable-turn-status.test.ts", @@ -1626,7 +1626,7 @@ "src/shared/structured-agent-session-turn-timing.test.ts", "src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts", "src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts", - "src/renderer/src/components/native-chat/NativeChatMessageList.turn-timing.test.tsx" + "src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx" ], "assertionRefs": [ { @@ -1651,19 +1651,19 @@ ] }, { - "file": "src/renderer/src/components/native-chat/NativeChatMessageList.turn-timing.test.tsx", + "file": "src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx", "assertions": ["renders a host-settled duration without ever clocking the turn locally"] } ], "evidenceRuns": [ { - "date": "2026-09-09", + "date": "2026-10-10", "runner": "local", "platform": "macos", - "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/native-chat-unverifiable-turn-status.test.ts src/main/codex/codex-requested-close-turn-timing.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts src/shared/structured-agent-session-turn-timing-retention.test.ts src/shared/structured-agent-session-turn-timing.test.ts src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts src/renderer/src/components/native-chat/NativeChatMessageList.turn-timing.test.tsx", + "command": "ORCA_BACKGROUND_LAUNCH=1 node config/scripts/run-vitest.mjs run --config config/vitest.config.ts src/shared/native-chat-unverifiable-turn-status.test.ts src/main/codex/codex-requested-close-turn-timing.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts src/shared/structured-agent-session-turn-timing-retention.test.ts src/shared/structured-agent-session-turn-timing.test.ts src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx --maxWorkers=4 --reporter=json --outputFile=notes/bun-migration/performance/ci-throughput-resume-20261009/next_capacity_wave_owner/reliability-path-correction/fresh-eight-root-stock-config.json", "result": "passed", - "durationSeconds": 4.65, - "summary": "58 tests passed across eight files at source commit 5acb404bf244cdc0bd3643c4e79beb189ce9a0cd; the following manifest-only commit records this run without changing tested source." + "durationSeconds": 5.688707999999999, + "summary": "87 tests passed across exactly eight current files at source commit aea00c5c02c3dccaa83aee3207ba6fcb586ffa5c using stock config/vitest.config.ts, Node 24.21.0, Bun 1.4.2 and four workers; no failures, skips or todo. Natural CLI close took 5.688708 seconds. The prior 2026-09-09 record (58 tests, 4.65 seconds, source 5acb404bf244cdc0bd3643c4e79beb189ce9a0cd) is preserved verbatim in Git history and notes/bun-migration/performance/ci-throughput-resume-20261009/next_capacity_wave_owner/reliability-path-correction/before.jsonc. This manifest-only change records the new run without changing tested source." } ], "runtimeBudget": { diff --git a/config/scripts/ci-cache-warmup-workflow.test.mjs b/config/scripts/ci-cache-warmup-workflow.test.mjs deleted file mode 100644 index 5c626caa703..00000000000 --- a/config/scripts/ci-cache-warmup-workflow.test.mjs +++ /dev/null @@ -1,118 +0,0 @@ -import { readFileSync } from 'node:fs' -import { expect, it } from 'vitest' -import { parse } from 'yaml' -import { NODE_SERVER_RUNNERS } from './node-server-qualification.mjs' - -const readWorkflow = (name) => - parse(readFileSync(new URL(`../../.github/workflows/${name}.yml`, import.meta.url), 'utf8')) -const workflow = readWorkflow('ci-cache-warmup') -const steps = workflow.jobs.warm.steps - -it('seeds new native keys on main when any declared build or probe input changes', () => { - const native = parse(readFileSync('.github/actions/prepare-native-runtime/action.yml', 'utf8')) - const hash = native.runs.steps.find((step) => step.id === 'native-cache-scope').env - .NATIVE_SOURCE_HASH - const files = [...hash.matchAll(/'([^']+)'/g)].map((match) => match[1]) - for (const file of files) { - expect( - workflow.on.push.paths.some((pattern) => - pattern.endsWith('/**') ? file.startsWith(pattern.slice(0, -2)) : file === pattern - ), - file - ).toBe(true) - } -}) - -it('warms the same Linux Node runtime the PR shards restore', () => { - const arm = workflow.jobs['warm-linux-arm'] - const install = arm.steps.find( - (step) => step.uses === './.github/actions/install-node-dependencies' - ) - const primer = readWorkflow('pr').jobs.preflight - expect(arm['runs-on']).toBe(primer['runs-on']) - expect(arm.steps.at(-1).run).toBe('node config/scripts/ensure-native-runtime.mjs --check-only') - expect(install.with).toMatchObject(primer.steps.find((step) => step.uses === install.uses).with) -}) - -it('populates shared Electron archives on both Linux architectures without changing the Node ABI', () => { - for (const name of ['warm', 'warm-linux-arm']) { - const steps = workflow.jobs[name].steps - const install = steps.find( - (step) => step.uses === './.github/actions/install-node-dependencies' - ) - expect(install.with['native-runtime']).toBe('node') - expect(install.with['cache-electron-package']).toBe('true') - expect(install.with['cache-pnpm-store-lookup-only']).toBe('true') - const populate = steps.find((step) => step.name === 'Populate shared Electron archive') - expect(populate.run).toBe('node config/scripts/install-electron-package-binary.mjs') - expect(steps.indexOf(populate)).toBeGreaterThan(steps.indexOf(install)) - } -}) - -it('publishes incremental state under a key and prefix that new PRs restore', () => { - const cache = steps.find((step) => step.id === 'typecheck-cache') - const prCache = readWorkflow('pr').jobs.preflight.steps.find((step) => step.name === cache.name) - expect(cache.with.path).toBe(prCache.with.path) - expect(cache.with['restore-keys']).toBe(prCache.with['restore-keys']) - expect(cache.with.key).toBe( - prCache.with.key.replace('github.event.pull_request.base.sha', 'github.sha') - ) - const check = steps.find((step) => step.run === 'pnpm run typecheck') - expect(check.if).toBe("steps.typecheck-cache.outputs.cache-hit != 'true'") - expect(steps.indexOf(check)).toBeGreaterThan(steps.indexOf(cache)) -}) - -it('bounds warming to the required platforms and validates changes without granting writes', () => { - expect(Object.keys(workflow.jobs)).toEqual([ - 'warm', - 'warm-linux-arm', - 'warm-windows', - 'warm-linux-package-fixtures' - ]) - expect(workflow.jobs.warm['timeout-minutes']).toBeLessThanOrEqual(10) - expect(workflow.permissions).toEqual({ contents: 'read' }) - expect(workflow.on.push.branches).toEqual(['main']) - expect(workflow.on.push.paths).toContain('.github/actions/prepare-native-runtime/**') - expect(workflow.on.schedule).toEqual([{ cron: '41 */6 * * *' }]) - expect(workflow.on.pull_request.paths).toContain('.github/workflows/ci-cache-warmup.yml') - expect(steps[0].with['persist-credentials']).toBe(false) -}) - -it('lets scheduled warmers wait while pushes, PR updates, and manual runs can replace active work', () => { - expect(workflow.concurrency).toEqual({ - group: 'ci-cache-warmup-${{ github.event.pull_request.number || github.ref }}', - 'cancel-in-progress': "${{ github.event_name != 'schedule' }}" - }) -}) - -it('keeps compiler changes validated without starting warmers for test-only edits', () => { - for (const event of ['push', 'pull_request']) { - const paths = workflow.on[event].paths - expect(paths).toContain('config/scripts/headless-detector-compiler-cache.mjs') - expect(paths).not.toContain('config/scripts/headless-detector-compiler-cache*') - expect(paths).not.toContain('config/scripts/headless-detector-compiler-cache.test.mjs') - expect(paths).not.toContain('config/scripts/ci-cache-warmup-workflow.test.mjs') - expect(paths).toContain('.github/workflows/ci-cache-warmup.yml') - expect(paths).toContain('.github/actions/prepare-headless-compiler/**') - } -}) - -it('warms and probes both Windows images with the persistence job runtime', () => { - const job = workflow.jobs['warm-windows'] - expect(job.strategy.matrix.os).toEqual( - NODE_SERVER_RUNNERS.filter((os) => os.startsWith('windows-')) - ) - expect(job['runs-on']).toBe('${{ matrix.os }}') - expect(job.strategy['fail-fast']).toBe(false) - expect(job['timeout-minutes']).toBeLessThanOrEqual(20) - expect(job.env.ORCA_BACKGROUND_LAUNCH).toBe('1') - expect(job.steps[0].with['persist-credentials']).toBe(false) - const install = job.steps.find( - (step) => step.uses === './.github/actions/install-node-dependencies' - ) - expect(install.with).toEqual({ - 'native-runtime': 'node', - 'cache-pnpm-store-lookup-only': 'true' - }) - expect(job.steps.at(-1).run).toBe('node config/scripts/ensure-native-runtime.mjs --check-only') -}) diff --git a/config/scripts/ci-native-toolchain.test.mjs b/config/scripts/ci-native-toolchain.test.mjs index f0d58b97639..9246b2207d5 100644 --- a/config/scripts/ci-native-toolchain.test.mjs +++ b/config/scripts/ci-native-toolchain.test.mjs @@ -10,16 +10,6 @@ const steps = parse(readFileSync('.github/actions/prepare-native-runtime/action. const toolchain = steps.find((step) => step.name === 'Use external node-gyp') describe('CI native toolchain preparation', () => { - it('probes only after both cache restore variants and before native rebuilding', () => { - const index = steps.indexOf(toolchain) - for (const id of ['native-cache-restore', 'native-cache-restore-only']) { - expect(index).toBeGreaterThan(steps.findIndex((step) => step.id === id)) - expect(toolchain.env.NATIVE_CACHE_HIT).toContain(`steps.${id}.outputs.cache-hit`) - } - expect(index).toBeLessThan(steps.findIndex((step) => step.name === 'Prepare native runtime')) - expect(toolchain.if).toBe("runner.os == 'Linux' && inputs.native-runtime == 'node'") - }) - // The action's toolchain workaround only runs in Linux Bash. it.skipIf(process.platform === 'win32').each([ ['node', 'true', '0', false], @@ -64,13 +54,4 @@ describe('CI native toolchain preparation', () => { rmSync(directory, { recursive: true, force: true }) } }) - - it('prepares Electron native modules through the existing rebuild path without the global toolchain', () => { - const preparation = steps.find((step) => step.name === 'Prepare native runtime') - expect(preparation.if).toBe("inputs.native-runtime != 'none'") - expect(preparation.env.NATIVE_RUNTIME).toBe('${{ inputs.native-runtime }}') - expect(preparation.run).toBe( - 'node config/scripts/ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME"' - ) - }) }) diff --git a/config/scripts/mobile-launch-contract-workflow.test.mjs b/config/scripts/mobile-launch-contract-workflow.test.mjs deleted file mode 100644 index fa24180e1fc..00000000000 --- a/config/scripts/mobile-launch-contract-workflow.test.mjs +++ /dev/null @@ -1,34 +0,0 @@ -import { existsSync, readFileSync } from 'node:fs' -import { dirname, matchesGlob, relative, resolve, sep } from 'node:path' -import { expect, it } from 'vitest' -import ts from 'typescript-api' -import { parse } from 'yaml' - -const projectDir = resolve(import.meta.dirname, '../..') -const workflow = parse(readFileSync(resolve(projectDir, '.github/workflows/mobile.yml'), 'utf8')) - -it.each(['agent-launch-mobile-replay', 'mobile-agent-launch-architecture'])( - 'runs Mobile Checks when a direct root dependency of %s changes', - (name) => { - const suite = resolve(projectDir, `mobile/src/tasks/${name}.test.ts`) - // Parse source only: the root test project must never load the mobile dependency graph. - const imports = ts - .preProcessFile(readFileSync(suite, 'utf8'), true) - .importedFiles.filter((entry) => entry.fileName.startsWith('.')) - .map((entry) => - relative(projectDir, resolve(dirname(suite), `${entry.fileName}.ts`)) - .split(sep) - .join('/') - ) - .filter((file) => file.startsWith('src/')) - - expect(imports).not.toEqual([]) - for (const file of imports) { - expect(existsSync(resolve(projectDir, file)), file).toBe(true) - expect( - workflow.on.pull_request.paths.some((pattern) => matchesGlob(file, pattern)), - file - ).toBe(true) - } - } -) diff --git a/config/scripts/mobile-web-app-web-overrides.test.mjs b/config/scripts/mobile-web-app-web-overrides.test.mjs deleted file mode 100644 index f903c48259c..00000000000 --- a/config/scripts/mobile-web-app-web-overrides.test.mjs +++ /dev/null @@ -1,131 +0,0 @@ -import { existsSync } from 'node:fs' -import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join, relative } from 'node:path' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' - -const projectDir = fileURLToPath(new URL('../..', import.meta.url)) -const mobileDir = join(projectDir, 'mobile') -const allowlistPath = join(mobileDir, 'web-entry', 'web-overrides.json') - -// Every tree the app entry can resolve a .web.* sibling out of: src and web-entry and packages -// through the builder's resolveExtensions, app through the route manifest's own sibling -// preference. packages is in the list because the dictation hook imports the vendored -// @orca/expo-two-way-audio, whose web module then reaches the page. -const SCANNED = ['src', 'app', 'web-entry', 'packages'] -const WEB_SIBLING = /\.web\.(tsx|ts|jsx|js)$/ - -async function listFiles(directory) { - const out = [] - for (const entry of await readdir(directory, { withFileTypes: true })) { - if (entry.name === 'node_modules') { - continue - } - const entryPath = join(directory, entry.name) - if (entry.isDirectory()) { - out.push(...(await listFiles(entryPath))) - } else if (entry.isFile()) { - out.push(entryPath) - } - } - return out -} - -/** Takes the root so the census can be run against a scratch tree and shown to fail. */ -export async function findWebSiblings(rootDir) { - const found = [] - for (const tree of SCANNED) { - const directory = join(rootDir, tree) - if (!existsSync(directory)) { - continue - } - for (const file of await listFiles(directory)) { - if (WEB_SIBLING.test(file)) { - found.push(relative(rootDir, file).split('\\').join('/')) - } - } - } - return found.sort() -} - -async function readAllowlist() { - return JSON.parse(await readFile(allowlistPath, 'utf8')) -} - -async function exists(path) { - return readFile(path).then( - () => true, - () => false - ) -} - -async function withScratch(run) { - const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-overrides-')) - try { - return await run(scratch) - } finally { - await rm(scratch, { recursive: true, force: true }) - } -} - -async function plant(scratch, file) { - await mkdir(join(scratch, file, '..'), { recursive: true }) - await writeFile(join(scratch, file), 'export default null\n', 'utf8') -} - -describe('mobile web app .web.* overrides', () => { - it('lists exactly the .web.* files on disk', async () => { - const { overrides } = await readAllowlist() - expect(overrides.map((entry) => entry.file).sort()).toEqual(await findWebSiblings(mobileDir)) - }) - - it('gives every override a non-web sibling, so the native build still has a module', async () => { - const { overrides } = await readAllowlist() - for (const { file } of overrides) { - const native = join(mobileDir, file.replace('.web.', '.')) - // A .web.tsx may shadow a .tsx or a .ts; try both before failing. - const alternative = native.replace(/\.tsx$/, '.ts').replace(/\.jsx$/, '.js') - expect( - (await exists(native)) || (await exists(alternative)), - `${file} has no non-web sibling` - ).toBe(true) - } - }) - - it('states a reason for every override', async () => { - const { overrides } = await readAllowlist() - for (const entry of overrides) { - expect(entry.reason.length, `${entry.file} has no reason`).toBeGreaterThan(20) - } - }) -}) - -// A census that scans only trees which happen to hold no .web.* file passes for the wrong reason. -// These plant one in each scanned tree and show the first assertion above would report it. -describe('the census scan', () => { - it('reports an unlisted .web.* in every tree it claims to cover', async () => { - const planted = { - src: 'src/transport/planted.web.ts', - app: 'app/h/[hostId]/edit.web.tsx', - 'web-entry': 'web-entry/planted.web.tsx', - packages: 'packages/expo-two-way-audio/src/Planted.web.ts' - } - for (const [tree, file] of Object.entries(planted)) { - await withScratch(async (scratch) => { - await plant(scratch, file) - expect( - await findWebSiblings(scratch), - `${tree} is scanned but ${file} went unseen` - ).toEqual([file]) - }) - } - }) - - it('skips node_modules, which vendors thousands of unrelated .web.js files', async () => { - await withScratch(async (scratch) => { - await plant(scratch, 'packages/x/node_modules/dep/index.web.js') - expect(await findWebSiblings(scratch)).toEqual([]) - }) - }) -}) diff --git a/config/scripts/orca-dev-bin.test.mjs b/config/scripts/orca-dev-bin.test.mjs index afcaf0fb797..1c500ae2a0c 100644 --- a/config/scripts/orca-dev-bin.test.mjs +++ b/config/scripts/orca-dev-bin.test.mjs @@ -5,15 +5,9 @@ import path from 'node:path' import { describe, expect, it } from 'vitest' const projectDir = path.resolve(import.meta.dirname, '../..') -const packageJson = JSON.parse(readFileSync(path.join(projectDir, 'package.json'), 'utf8')) const wrapperPath = path.join(projectDir, 'config', 'scripts', 'orca-dev.mjs') describe('orca-dev package bin', () => { - it('uses a Node entrypoint for cross-platform package installs', () => { - expect(packageJson.bin['orca-dev']).toBe('./config/scripts/orca-dev.mjs') - expect(readFileSync(wrapperPath, 'utf8')).toMatch(/^#!\/usr\/bin\/env node\n/) - }) - it('runs the dev CLI through Node without requiring Bash', () => { const root = mkdtempSync(path.join(tmpdir(), 'orca-dev-bin-')) const cliEntry = path.join(root, 'cli-entry.cjs') diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index 4432f795d1c..0173a07bc50 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -1,57 +1,27 @@ import { DEDICATED_E2E_SPECS } from './ci-e2e-job-selection.mjs' import { linuxInstallPackageList } from './pr-e2e-linux-packages.test-fixture.mjs' -import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { join, resolve } from 'node:path' -import { parse as parseJsonc } from 'jsonc-parser' import { describe, expect, it } from 'vitest' import { parse as parseYaml } from 'yaml' import { hasNativeImeSourceChange, hasSshSourceChange, - NATIVE_IME_SOURCE_ROUTE_IDS, PR_E2E_SOURCE_ROUTES, selectPrE2eSpecs, SSH_SOURCE_ROUTE_IDS } from './pr-e2e-source-routing.mjs' -import { - EXPECTED_NATIVE_IME_TESTS, - IME_ENGAGEMENT_RECEIPT_ENV -} from './terminal-ime-engagement-receipt.mjs' const projectDir = resolve(import.meta.dirname, '../..') const prWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')) const e2eWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8')) -const reliabilityManifest = parseJsonc( - readFileSync(join(projectDir, 'config/reliability-gates.jsonc'), 'utf8') -) -const playwrightConfig = readFileSync(join(projectDir, 'tests/playwright.config.ts'), 'utf8') -const sshDockerRunner = readFileSync( - join(projectDir, 'config/scripts/run-ssh-docker-terminal-parking-e2e.mjs'), - 'utf8' -) -const nativeImeWorkflow = parseYaml( - readFileSync(join(projectDir, '.github/workflows/terminal-ime-e2e.yml'), 'utf8') -) -const nativeImeRunner = readFileSync( - join(projectDir, 'config/scripts/run-terminal-ibus-hangul-e2e.mjs'), - 'utf8' -) -const nativeImeSpec = readFileSync( - join(projectDir, 'tests/e2e/terminal-ibus-hangul-native.spec.ts'), - 'utf8' -) - const filterStep = prWorkflow.jobs.code_paths.steps.find( (step) => step.name === 'Filter changed E2E specs' ) const rollbackStep = prWorkflow.jobs.preflight.steps.find( (step) => step.name === 'Check VM runtime rollback compatibility' ) -const verifyStep = prWorkflow.jobs.verify.steps.find( - (step) => step.name === 'Require successful checks' -) - /** The route that sends a change to the two-Electron restart-survival spec. */ const restartSurvivalRoute = PR_E2E_SOURCE_ROUTES.find( (route) => route.id === 'client-hosted-browser.restart-survival' @@ -94,53 +64,6 @@ describe('restart-survival E2E routing', () => { }) describe('PR E2E gate contract', () => { - it('keeps E2E advisory while the suite is red on main', () => { - // Why: pin the deliberate choice so it reads as intentional rather than as - // the "forgot to wire the gate" bug this file originally caught. Gating on a - // suite that fails every scheduled run would block the PRs that fix it. - // Flipping to blocking means updating this expectation too — see the comment - // on verify's Require-successful-checks step for the exact wiring. - expect(prWorkflow.jobs.verify.needs).not.toContain('e2e') - expect(verifyStep.env.E2E).toBeUndefined() - expect(verifyStep.run).not.toContain('$E2E') - }) - - it('passes only changed specs to the reusable E2E workflow', () => { - // Why: without this the job could lose its filter and run on every PR — the - // cost the path filter exists to avoid — while the gate assertions above - // stay green. - expect(prWorkflow.jobs.e2e.needs).toEqual(['code_paths', 'preflight']) - expect(prWorkflow.jobs.e2e.if).toContain("needs.code_paths.outputs.e2e_should_run == 'true'") - expect(prWorkflow.jobs.code_paths.outputs.e2e_should_run).toBe( - '${{ steps.e2e_filter.outputs.should_run }}' - ) - expect(prWorkflow.jobs.code_paths.outputs.test_files).toBe( - '${{ steps.e2e_filter.outputs.test_files }}' - ) - expect(prWorkflow.jobs.e2e.with.ref).toBe('${{ github.event.pull_request.head.sha }}') - expect(prWorkflow.jobs.e2e.with.test_files).toBe('${{ needs.code_paths.outputs.test_files }}') - }) - - it('enforces every job verify depends on', () => { - // Why: derive from verify.needs rather than hardcoding, so adding a required - // job without adding it to the strict loop fails here instead of silently - // leaving that job unenforced. This is what caught GIT_COMPATIBILITY and - // SHELL_CONTRACTS being absent from an earlier hardcoded list. - const successMarker = '# Require success when the PR has code-relevant changes' - const successLoop = verifyStep.run.slice(verifyStep.run.indexOf(successMarker)) - expect(successLoop.length).toBeGreaterThan(0) - expect(verifyStep.run).toContain('"$CODE_PATHS" != "success"') - for (const job of prWorkflow.jobs.verify.needs) { - const envVar = job.replaceAll('-', '_').toUpperCase() - expect(verifyStep.env[envVar]).toBe(`\${{ needs.${job}.result }}`) - if (job === 'code_paths' || job === 'preflight') { - continue - } - expect(successLoop).toContain(`"$${envVar}"`) - expect(verifyStep.env[`${envVar}_SHOULD_RUN`]).toBe(`\${{ needs.code_paths.outputs.${job} }}`) - } - }) - it('selects modified Playwright specs without running deleted tests', () => { expect(filterStep.run).toContain('--diff-filter=AMCR') expect(filterStep.run).toContain('config/scripts/pr-e2e-source-routing.mjs') @@ -154,99 +77,6 @@ describe('PR E2E gate contract', () => { ).toEqual(['tests/e2e/active-view-restart-restore.spec.ts']) }) - it('uses one runner for changed specs and keeps full runs sharded', () => { - expect(e2eWorkflow.jobs.e2e.if).toBe("inputs.test_files == ''") - expect(e2eWorkflow.jobs['changed-e2e'].if).toBe( - "inputs.test_files != '' && (github.event_name != 'pull_request' || inputs.run_changed_e2e)" - ) - expect(e2eWorkflow.jobs['changed-e2e'].strategy).toBeUndefined() - expect(e2eWorkflow.jobs.e2e.strategy.matrix.include).toEqual( - Array.from({ length: 14 }, (_, index) => ({ - shard: `${index + 1}/14`, - shard_name: `${index + 1}-of-14` - })) - ) - const changedRun = e2eWorkflow.jobs['changed-e2e'].steps.find( - (step) => step.name === 'Run changed E2E specs' - ) - expect(changedRun.env.TEST_FILES_JSON).toBe('${{ inputs.test_files }}') - expect(DEDICATED_E2E_SPECS).toContain('tests/e2e/ssh-startup-exec-readiness.spec.ts') - expect(DEDICATED_E2E_SPECS).toContain('tests/e2e/paired-startup-exec-readiness.spec.ts') - expect(DEDICATED_E2E_SPECS).toContain( - 'tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts' - ) - expect(DEDICATED_E2E_SPECS).toContain('tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts') - expect(changedRun.run).toContain('if [ "${#TEST_FILES[@]}" -eq 0 ]') - expect(changedRun.run).toContain('grep -l \'@headful\' "${TEST_FILES[@]}"') - expect(changedRun.run).toContain('E2E_PROJECT_ARGS+=(--project=electron-headful)') - expect(changedRun.run).toContain( - 'pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}"' - ) - expect(playwrightConfig).toContain('retries: 0') - const steps = e2eWorkflow.jobs.e2e.steps.filter((step) => - step.run?.includes('tests/e2e/worktree-switch-first-paint.spec.ts') - ) - expect(steps).toHaveLength(1) - expect(steps[0].if).toBe("matrix.shard == '1/14'") - expect(steps[0].run).toContain('xvfb-run --auto-servernum') - expect(steps[0].run).toContain('--project=electron-headful --workers=1') - }) - - it('keeps startup-exec live parity in the isolated SSH lane', () => { - const sshLaneCondition = e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if - expect(sshLaneCondition).toContain("inputs.test_files == ''") - expect(sshLaneCondition).toContain('tests/e2e/ssh-startup-exec-readiness.spec.ts') - expect(sshLaneCondition).toContain('tests/e2e/paired-startup-exec-readiness.spec.ts') - expect(sshDockerRunner).toContain('tests/e2e/ssh-startup-exec-readiness.spec.ts') - expect(sshDockerRunner).toContain('tests/e2e/paired-startup-exec-readiness.spec.ts') - expect(sshDockerRunner).toContain("'electron-headless'") - expect(sshDockerRunner).toContain("'electron-headful'") - }) - - it('reuses the composite install action instead of duplicating pnpm setup', () => { - const installFor = (jobName) => - e2eWorkflow.jobs[jobName].steps.find( - (step) => step.uses === './.github/actions/install-node-dependencies' - ) - - expect(installFor('build').with['native-runtime']).toBe('node') - for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) { - expect(installFor(jobName).with['native-runtime'], jobName).toBe('electron') - } - }) - - it('installs zsh in every Linux lane that can run paired startup readiness', () => { - for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) { - const installStep = e2eWorkflow.jobs[jobName].steps.find((step) => - step.name.startsWith('Install native build') - ) - expect(linuxInstallPackageList(installStep, jobName), jobName).toMatch(/(^|\s)zsh(\s|$)/) - } - }) - - it('keeps dedicated E2E workflows from self-triggering on pull requests', () => { - // Why this still holds for terminal-ime-e2e.yml now that pr.yml runs it: pr.yml reaches it - // through workflow_call, behind the path filter. A pull_request trigger here would run a - // real ibus session on every PR, which is the cost the filter exists to avoid. - const dedicatedWorkflows = [ - 'golden-e2e-experiment.yml', - 'linux-wayland-gpu-sandbox.yml', - 'terminal-ime-e2e.yml', - 'win-crash-survival-e2e.yml', - 'windows-terminal-restart-e2e.yml' - ] - - for (const file of dedicatedWorkflows) { - const workflow = parseYaml(readFileSync(join(projectDir, '.github/workflows', file), 'utf8')) - expect(workflow.on.pull_request, file).toBeUndefined() - } - }) - - it('scopes detection to the PR range so base drift cannot false-trigger', () => { - expect(filterStep.run).toMatch(/diff-base\.mjs "\$BASE"[\s\S]*"\$DIFF_BASE" HEAD/) - expect(filterStep.run).toContain('set -euo pipefail') - }) - it('maps SSH source edits onto the Docker-backed specs they can break', () => { // Why: the Docker-SSH specs self-skip without ORCA_E2E_SSH_DOCKER, and the only // trigger used to be "someone edited a spec" — four pane-restore regressions shipped @@ -507,37 +337,6 @@ describe('PR E2E gate contract', () => { ) }) - it('puts the real-IME lane on the PR gate behind the IME source filter', () => { - // Why a whole lane and not a spec in changed-e2e: the harness is an ibus-daemon, an xfwm4 - // session, and an X11 display; the generic lane has none of them and the spec would skip. - expect(nativeImeWorkflow.on.workflow_call).toBeDefined() - expect(prWorkflow.jobs.terminal_ime_native.uses).toBe( - './.github/workflows/terminal-ime-e2e.yml' - ) - expect(prWorkflow.jobs.terminal_ime_native.needs).toBe('code_paths') - expect(prWorkflow.jobs.terminal_ime_native.if).toBe( - "needs.code_paths.outputs.native_ime_source_changed == 'true'" - ) - expect(prWorkflow.jobs.code_paths.outputs.native_ime_source_changed).toBe( - '${{ steps.e2e_filter.outputs.native_ime_source_changed }}' - ) - expect(filterStep.run).toContain('pr-e2e-source-routing.mjs --native-ime-source') - expect(filterStep.run).toContain('native_ime_source_changed=$NATIVE_IME_SOURCE_CHANGED') - - // Why: continue-on-error would report the lane green and hide every failure it exists to - // surface. Advisory here means "absent from verify.needs", not "always passes". - expect(prWorkflow.jobs.terminal_ime_native['continue-on-error']).toBeUndefined() - expect(prWorkflow.jobs.verify.needs).not.toContain('terminal_ime_native') - expect(verifyStep.env.TERMINAL_IME_NATIVE).toBeUndefined() - - for (const id of NATIVE_IME_SOURCE_ROUTE_IDS) { - expect( - PR_E2E_SOURCE_ROUTES.map((route) => route.id), - id - ).toContain(id) - } - }) - it('triggers the real-IME lane from every surface an input method can judge', () => { for (const file of [ 'src/renderer/src/components/terminal-pane/terminal-ime-composition-route.ts', @@ -574,68 +373,6 @@ describe('PR E2E gate contract', () => { } }) - it('gives every input-method-gated spec a lane that runs it, or an honest exemption', () => { - // Why this shape: a spec gated on a native-IME env var that no runner sets is a skip that - // reports as a pass. This repo already carries such specs; the point is that they are named - // as gaps rather than counted as coverage. - const nativeGateExpression = /ORCA_E2E_NATIVE_(?:IBUS_HANGUL|MACOS_KOREAN)\s*[!=]==\s*['"]1['"]/ - const nativeGatedSpecs = readdirSync(join(projectDir, 'tests/e2e')) - .filter((file) => file.endsWith('.spec.ts')) - .map((file) => `tests/e2e/${file}`) - .filter((spec) => nativeGateExpression.test(readFileSync(join(projectDir, spec), 'utf8'))) - expect(nativeGatedSpecs.length).toBeGreaterThan(0) - - // The macOS spec needs a native input source; PR and scheduled IME lanes use Linux. - const unreachableSpecs = new Set(['tests/e2e/terminal-macos-2set-korean-native.spec.ts']) - const unclaimed = nativeGatedSpecs.filter( - (spec) => !unreachableSpecs.has(spec) && !nativeImeRunner.includes(spec) - ) - expect( - unclaimed, - `Native-IME-gated specs claimed by no lane runner: ${unclaimed.join(', ')}` - ).toEqual([]) - - for (const spec of unreachableSpecs) { - expect(nativeGatedSpecs, spec).toContain(spec) - expect(nativeImeRunner.includes(spec), `${spec} is exempt but still invoked`).toBe(false) - } - }) - - it('requires proof an input method engaged before the lane may report success', () => { - // Why this is the assertion that matters: every other check in this file protects a job from - // not running. This one protects a job that ran from having exercised nothing. - expect(nativeImeRunner).toContain('verifyImeEngagementReceipts') - expect(nativeImeRunner).toContain(`[IME_ENGAGEMENT_RECEIPT_ENV]: receiptPath`) - expect(nativeImeSpec).toContain('appendImeEngagementReceipt(testInfo.title, trace)') - - // Why: the synthetic CDP step runs first in the same job. Under the default success() - // condition its failure skipped the real-IME step, so the half that needs an input method - // reported nothing on exactly the changes that broke IME code. - const nativeStep = nativeImeWorkflow.jobs['linux-x11'].steps.find( - (step) => step.name === 'Run native IBus Hangul exact-byte tests' - ) - expect(nativeStep.if).toBe('!cancelled()') - - // Why a literal comparison: the spec cannot import the .mjs module, so the env var name is - // written twice and would otherwise drift into a receipt nobody reads. - const specSideReceipt = readFileSync( - join(projectDir, 'tests/e2e/terminal-ime-engagement-receipt.ts'), - 'utf8' - ) - expect(specSideReceipt).toContain(`'${IME_ENGAGEMENT_RECEIPT_ENV}'`) - - // Why pin the titles: the runner requires one receipt per name, so a rename that nobody - // mirrored here would fail the lane loudly instead of quietly halving it. - const nativeDigitSpec = readFileSync( - join(projectDir, 'tests/e2e/terminal-hangul-terminating-digit-native.spec.ts'), - 'utf8' - ) - expect(nativeDigitSpec).toContain('appendImeEngagementReceipt(testInfo.title, trace)') - for (const title of EXPECTED_NATIVE_IME_TESTS) { - expect(nativeImeSpec + nativeDigitSpec, title).toContain(title) - } - }) - it('keeps the native IME spec out of the lane that would silently skip it', () => { expect(DEDICATED_E2E_SPECS).toContain('tests/e2e/terminal-ibus-hangul-native.spec.ts') // Why it still has to be routed: the dedicated lane is selected by the same route, so the @@ -644,27 +381,4 @@ describe('PR E2E gate contract', () => { 'tests/e2e/terminal-ibus-hangul-native.spec.ts' ) }) - - it('keeps source-routed sentinels registered to their reliability gates', () => { - const routedGateIds = [ - 'terminal-startup.quick-command-pre-bind-recovery', - 'quick-open.paired-host-path-search', - 'terminal-session.host-cold-park-stream-continuity', - 'terminal-provider.ssh-remote-reattach-contract', - 'terminal-session.remote-pane-layout-retry' - ] - for (const gateId of routedGateIds) { - const route = PR_E2E_SOURCE_ROUTES.find((candidate) => candidate.id === gateId) - const gate = reliabilityManifest.gates.find((candidate) => candidate.id === gateId) - expect(route, gateId).toBeDefined() - expect(gate, gateId).toMatchObject({ maturity: 'experimental', protection: 'partial' }) - for (const spec of route.specs) { - expect(gate.testFiles, gateId).toContain(spec) - expect( - gate.commands.some((command) => command.includes(spec)), - gateId - ).toBe(true) - } - } - }) }) diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 463abc16040..0aa80e4963f 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -1,56 +1,10 @@ -import { existsSync, globSync, readFileSync } from 'node:fs' +import { readFileSync } from 'node:fs' import { runInNewContext } from 'node:vm' import { parse } from 'yaml' import { describe, expect, it } from 'vitest' -import { UNIT_EXCLUDE } from './ci-unit-files.mjs' -import { MOBILE_WEB_APP_DEPENDENCIES_REQUIRED_ENV } from './mobile-web-app-bundle-dependencies.mjs' const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) -const prTestLocWorkflow = parse(readFileSync('.github/workflows/pr-test-loc.yml', 'utf8')) -const releasePolicyWorkflow = parse(readFileSync('.github/workflows/release-policy.yml', 'utf8')) -const issueLabelWorkflow = parse(readFileSync('.github/workflows/issue-os-labeler.yaml', 'utf8')) const unitTestWorkflow = parse(readFileSync('.github/workflows/unit-tests.yml', 'utf8')) -const nodeNextWorkflow = parse(readFileSync('.github/workflows/node-next-compat.yml', 'utf8')) -const dependencyAction = parse( - readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8') -) -const nativeAction = parse( - readFileSync('.github/actions/prepare-native-runtime/action.yml', 'utf8') -) -const packageJson = JSON.parse(readFileSync('package.json', 'utf8')) -const pnpmWorkspace = parse(readFileSync('pnpm-workspace.yaml', 'utf8')) -const shellContractFiles = [ - 'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts', - 'src/main/daemon/shell-ready.test.ts', - 'src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts', - 'src/main/providers/__tests__/shell-ready-framework-example.test.ts', - 'src/main/pty/omp-shell-wrapper-alias-safety.test.ts', - 'src/main/pty/omp-shell-wrapper.node-pty.test.ts', - 'src/main/shell-startup-feature-channel.test.ts', - 'src/main/zsh-deferred-startup-line-init.live-shell.test.ts', - 'src/main/zsh-scoped-histfile.live-shell.test.ts', - 'src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts', - 'src/main/zsh-wrapper-version-mismatch.live-shell.test.ts', - 'src/main/runtime/structured-session-cli-login-shell.live-shell.test.ts', - 'src/shared/posix-command-path-lookup.test.ts' -] -const patchedNodePtyContractFiles = [ - 'src/main/daemon/node-pty-fd-leak.test.ts', - 'src/shared/fish-query-reply-child-stdin.node-pty.test.ts' -] -const nativeShellContractFiles = [...shellContractFiles, ...patchedNodePtyContractFiles] -const testFilePatterns = [ - 'config/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}', - 'src/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}', - 'tests/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}', - 'tests/tools/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}' -] -// Why the harness import counts: the zsh startup hook runs from a `precmd`, so -// its tests drive a real zsh through a PTY in zsh-startup-hook-pty-harness -// rather than calling spawnSync('zsh') themselves. Without this branch the rule -// silently stops noticing the very tests that need the lane's zsh install. -const realZshUsage = - /(?:spawnSync|execFileSync|spawn)\(\s*['"](?:\/(?:usr\/)?bin\/)?zsh['"]|program:\s*['"](?:\/(?:usr\/)?bin\/)?zsh['"]|spawnSync\(\s*['"]which['"]\s*,\s*\[\s*['"]zsh['"]|name:\s*['"]zsh['"]\s*,\s*path:\s*executablePath|from '[^']*zsh-startup-hook-pty-harness'/ describe('PR workflow parallelism', () => { it('enforces process-host boundaries in the required static-analysis checks', () => { @@ -61,489 +15,6 @@ describe('PR workflow parallelism', () => { expect(guard?.['continue-on-error']).toBeUndefined() }) - it('keeps lightweight orchestration jobs on the free slim runner', () => { - expect(workflow.jobs.code_paths['runs-on']).toBe('ubuntu-slim') - expect(workflow.jobs.preflight['runs-on']).toBe('ubuntu-24.04-arm') - expect(workflow.jobs.verify['runs-on']).toBe('ubuntu-slim') - expect(prTestLocWorkflow.jobs.loc['runs-on']).toBe('ubuntu-slim') - expect(releasePolicyWorkflow.jobs.enforce['runs-on']).toBe('ubuntu-slim') - expect(issueLabelWorkflow.jobs['apply-os-label']['runs-on']).toBe('ubuntu-slim') - }) - - it('cancels superseded runs for the same pull request', () => { - expect(workflow.concurrency.group).toBe('pr-checks-${{ github.event.pull_request.number }}') - expect(workflow.concurrency['cancel-in-progress']).toBe(true) - expect(workflow.jobs.test.if).toContain('!cancelled()') - expect(workflow.jobs.test.if).not.toContain('always()') - expect(workflow.jobs.verify.if).toBe('${{ !cancelled() }}') - }) - - it('grants the PR workflow read-only repository access', () => { - expect(workflow.permissions).toEqual({ contents: 'read' }) - }) - - it('runs PR shards on ARM and both Node versions on x86 daily', () => { - const sharedTest = unitTestWorkflow.jobs.test - const testStep = sharedTest.steps.find((step) => step.name === 'Test shard') - const installStep = sharedTest.steps.find( - (step) => step.uses === './.github/actions/install-node-dependencies' - ) - const staticInstall = workflow.jobs.preflight.steps.find( - (step) => step.uses === './.github/actions/install-node-dependencies' - ) - const nodeNextPrimerInstall = nodeNextWorkflow.jobs.test_native_cache.steps.find( - (step) => step.uses === './.github/actions/install-node-dependencies' - ) - - expect(workflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml') - expect(JSON.parse(workflow.jobs.test.with.node_versions)).toEqual(['24']) - expect(nodeNextWorkflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml') - expect(JSON.parse(nodeNextWorkflow.jobs.test.with.node_versions)).toEqual(['24', '26']) - expect(workflow.jobs.test.with.runner).toBe('ubuntu-24.04-arm') - expect(workflow.jobs.preflight['runs-on']).toBe('ubuntu-24.04-arm') - expect(sharedTest['runs-on']).toBe('${{ inputs.runner }}') - expect(unitTestWorkflow.on.workflow_call.inputs.runner.default).toBe('ubuntu-latest') - expect(nodeNextWorkflow.jobs.test.with.runner).toBeUndefined() - expect(nodeNextWorkflow.jobs.test_native_cache['runs-on']).toBe('ubuntu-latest') - expect(nodeNextWorkflow.jobs.test_native_cache.strategy.matrix.node).toEqual(['24', '26']) - const relay = unitTestWorkflow.jobs.relay_integration - expect(relay.strategy.matrix.node).toBe('${{ fromJSON(inputs.node_versions) }}') - expect(relay['runs-on']).toBe('ubuntu-latest') - expect( - relay.steps.find((step) => step.uses === './.github/actions/install-node-dependencies').with[ - 'node-version' - ] - ).toBe('${{ matrix.node }}') - expect(nodeNextWorkflow.on.schedule).toHaveLength(1) - expect(nodeNextWorkflow.on.workflow_dispatch).toBeNull() - expect(sharedTest.strategy.matrix.node).toBe('${{ fromJSON(inputs.node_versions) }}') - expect(sharedTest.strategy.matrix.shard).toBe('${{ fromJSON(inputs.shards) }}') - // PR planning overlaps typecheck; the daily suite keeps its separate planner. - expect(sharedTest.needs).toBeUndefined() - expect(unitTestWorkflow.jobs.plan).toBeUndefined() - expect(unitTestWorkflow.on.workflow_call.inputs.shards.required).toBe(true) - expect(installStep.with['node-version']).toBe('${{ matrix.node }}') - expect(installStep.with['cache-electron-package']).toBe('true') - expect(testStep.run).toContain('--shard=${{ matrix.shard.index }}/${{ matrix.shard.count }}') - for (const testFile of nativeShellContractFiles) { - expect(UNIT_EXCLUDE).toContain(testFile) - } - expect(staticInstall.with['native-runtime']).toBe('node') - expect(staticInstall.with['node-version']).toBe('24') - expect(workflow.jobs.test.needs).toContain('preflight') - expect(workflow.jobs.test_native_cache).toBeUndefined() - expect(nodeNextPrimerInstall.with['native-runtime']).toBe('node') - expect(nodeNextPrimerInstall.with['node-version']).toBe('${{ matrix.node }}') - expect(nodeNextWorkflow.jobs.test.needs).toEqual(['test_native_cache', 'unit_plan']) - }) - - it('runs real-shell coverage once outside the general shards', () => { - const shellStep = workflow.jobs.shell_contracts.steps.find( - (step) => step.name === 'Test real shell contracts' - ) - const shellInstall = workflow.jobs.shell_contracts.steps.find( - (step) => step.uses === './.github/actions/install-node-dependencies' - ) - // Why parsed rather than substring-matched: the step name changes as shells are - // added, and `includes('fish')` would also match a comment or a longer package. - const aptPackages = (step) => - (step.run?.match(/apt-get install[^\n]*/)?.[0] ?? '') - .split(/\s+/) - .filter((token) => !['apt-get', 'install', 'sudo', ''].includes(token)) - .filter((token) => !token.startsWith('-')) - const requiredShells = ['zsh', 'fish'] - const jobsInstallingShells = Object.entries(workflow.jobs) - .filter(([, job]) => - (job.steps ?? []).some((step) => - aptPackages(step).some((packageName) => requiredShells.includes(packageName)) - ) - ) - .map(([name]) => name) - - expect(shellStep).toBeDefined() - expect(shellInstall).toBeDefined() - expect(shellStep.run.split(/\s+/)).toContain('--maxWorkers=1') - // Why the whole workflow, not just the general shards: any other lane installing - // these shells would silently start running the real-shell tests twice. - expect(jobsInstallingShells).toEqual(['shell_contracts']) - // Why each shell is asserted: the live tests skip themselves when the binary is - // missing, so a dropped package silently empties this lane instead of failing it. - const shellPackages = workflow.jobs.shell_contracts.steps.flatMap(aptPackages) - for (const shell of requiredShells) { - expect(shellPackages).toContain(shell) - } - expect(shellInstall.with['native-runtime']).toBe('node') - for (const testFile of nativeShellContractFiles) { - expect(shellStep.run).toContain(testFile) - } - }) - - it('refreshes the apt index once while adding the fish PPA', () => { - const installStep = workflow.jobs.shell_contracts.steps.find( - (step) => step.name === 'Install zsh and fish' - ) - // Comment lines mention both commands by name, so count the executed ones only. - const commands = installStep.run - .split('\n') - .filter((line) => !line.trim().startsWith('#')) - .join('\n') - const updates = commands.match(/apt-get update/g) ?? [] - // Anchored to the start of a line so the retry message that names the command in - // prose is not mistaken for an invocation of it. - const addRepoCalls = commands - .split('\n') - .map((line) => line.trim()) - .filter((line) => /^(sudo\s+)?add-apt-repository\b/.test(line)) - - // add-apt-repository refreshes every configured repo unless told not to, so an - // update on each side of it made this step pay for three full passes. - expect(updates).toHaveLength(1) - expect(addRepoCalls.length).toBeGreaterThan(0) - for (const call of addRepoCalls) { - expect(call.split(/\s+/)).toContain('-n') - } - // The one remaining update has to come after the PPA is on the list, or the fish - // index it exists to fetch would not be there yet. - expect(commands.lastIndexOf('add-apt-repository')).toBeLessThan( - commands.indexOf('apt-get update') - ) - }) - - it('bounds the shell lane so a stalled apt mirror cannot hold the run open', () => { - const job = workflow.jobs.shell_contracts - // A passing run of this job takes ~4.5 minutes, almost all of it package download. - // Without a job bound a stalled mirror runs to GitHub's 6h default, and because this - // is a required check it holds the whole run open and blocks `gh run rerun --failed`. - expect(job['timeout-minutes']).toBeGreaterThan(0) - expect(job['timeout-minutes']).toBeLessThanOrEqual(30) - - const installStep = job.steps.find((step) => step.name === 'Install zsh and fish') - // apt applies no wall-clock bound to a stalled mirror on its own. These turn an - // unbounded hang into a bounded, retried, legible failure. - expect(installStep.run).toMatch(/Acquire::http::Timeout/) - expect(installStep.run).toMatch(/Acquire::https::Timeout/) - expect(installStep.run).toMatch(/Acquire::Retries/) - // Retries multiply: a first attempt at 30s x 3 retries across every index file turned - // a dead mirror into a ~15 minute stall. One attempt, then move on. - expect(installStep.run).toMatch(/Acquire::Retries "1"/) - // Acquire timeouts are per-connection, so they cannot bound the command as a whole. - // Only a wall-clock bound can, and both apt invocations need one. - expect(installStep.run).toMatch(/timeout \d+ sudo apt-get update/) - expect(installStep.run).toMatch(/timeout \d+ sudo apt-get install/) - }) - - it('bounds package tooling refresh and keeps required installation fatal', () => { - const steps = workflow.jobs.package.steps - const installStep = steps.find((step) => step.id === 'linux-package-tools') - const commands = installStep.run - .split('\n') - .filter((line) => !line.trim().startsWith('#')) - .join('\n') - - expect(installStep.background).toBe(true) - expect(commands).toContain('Acquire::http::Timeout "15";') - expect(commands).toContain('Acquire::https::Timeout "15";') - expect(commands).toContain('Acquire::Retries "1";') - expect(commands).toMatch(/^timeout 120 sudo apt-get update \|\| true$/m) - expect(commands).toMatch(/^timeout 300 sudo apt-get install -y cpio rpm$/m) - expect(commands.trim().split('\n').at(-1)).toBe('timeout 300 sudo apt-get install -y cpio rpm') - expect(steps.find((step) => step.wait?.includes('linux-package-tools')).wait).toEqual([ - 'linux-package-tools', - 'web-client' - ]) - }) - - it('keeps every real-zsh test in the dedicated shell lane', () => { - const discoveredFiles = globSync(testFilePatterns) - // Why this file is excluded: it carries the detector pattern as a literal - // and would otherwise match itself. - .filter((testFile) => testFile !== 'config/scripts/pr-workflow-parallelism.test.mjs') - // TypeScript builds can leave an ignored JavaScript companion beside a source - // test. Inspect the source file once so generated output cannot duplicate it. - .filter( - (testFile) => - !testFile.endsWith('.js') || - !['.ts', '.tsx', '.mjs', '.cjs'].some((extension) => - existsSync(testFile.replace(/\.js$/, extension)) - ) - ) - .filter((testFile) => realZshUsage.test(readFileSync(testFile, 'utf8'))) - .sort() - - expect(discoveredFiles).toEqual([...shellContractFiles].sort()) - }) - - it('overlaps bundles with independent output directories', () => { - const buildStep = workflow.jobs.package.steps.find( - (step) => step.name === 'Build package inputs' - ) - - expect(buildStep.run).toContain('scripts=(build:relay build:electron-vite:parallel)') - expect(buildStep.run).toContain('pnpm run "$script" &') - expect( - workflow.jobs.package.steps.find( - (step) => step.name === 'Project web client from renderer build' - ).run - ).toBe('pnpm run build:web-from-renderer') - expect(packageJson.scripts['build:desktop']).toContain('pnpm run build:web-from-renderer') - expect(packageJson.scripts['build:release']).toContain('pnpm run build:web-from-renderer') - }) - - it('smokes managed-hook companions under their supported Node 18 runtime', () => { - const steps = workflow.jobs.managed_hook_node18.steps - const installIndex = steps.findIndex( - (step) => step.uses === './.github/actions/install-node-dependencies' - ) - const buildIndex = steps.findIndex((step) => step.run === 'pnpm run build:relay') - const node18Index = steps.findIndex( - (step) => step.uses === 'actions/setup-node@v6' && step.with['node-version'] === '18' - ) - const smokeIndex = steps.findIndex( - (step) => step.run === 'node config/scripts/smoke-managed-hook-runtime-node18.mjs' - ) - - const processHostIndex = steps.findIndex( - (step) => step.run === 'node config/scripts/smoke-process-host-node18.mjs' - ) - - expect(installIndex).toBeLessThan(buildIndex) - expect(buildIndex).toBeLessThan(node18Index) - expect(node18Index).toBeLessThan(smokeIndex) - expect(node18Index).toBeLessThan(processHostIndex) - }) - - it('restores the pnpm store before dependency installation', () => { - const steps = dependencyAction.runs.steps - const pnpmIndex = steps.findIndex((step) => step.name === 'Setup pnpm') - const nodeIndex = steps.findIndex((step) => step.name === 'Setup Node.js') - const requestedNodeIndex = steps.findIndex((step) => step.name === 'Setup requested Node.js') - - expect(pnpmIndex).toBeLessThan(nodeIndex) - expect(pnpmIndex).toBeLessThan(requestedNodeIndex) - const packageManagerVersion = /^pnpm@([^+]+)/.exec(packageJson.packageManager)?.[1] - expect(packageManagerVersion).toBe('12.8.1') - expect(steps[pnpmIndex].uses).toBe('pnpm/setup@v2') - expect(steps[pnpmIndex].with.version).toBeUndefined() - expect(steps[pnpmIndex].with.install).toBe(false) - const saveOutsidePrs = - "${{ github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && steps.pnpm-store-mode.outputs.lookup-only != 'true' && 'pnpm' || '' }}" - expect(steps[nodeIndex].with.cache).toBe(saveOutsidePrs) - expect(steps[nodeIndex].if).toBe("inputs.node-version == ''") - expect(steps[requestedNodeIndex].if).toBe("inputs.node-version != ''") - expect(steps[requestedNodeIndex].with['node-version']).toBe('${{ inputs.node-version }}') - expect(steps[requestedNodeIndex].with.cache).toBe(saveOutsidePrs) - const restoreIndex = steps.findIndex( - (step) => step.name === 'Restore pnpm download store without saving' - ) - expect(restoreIndex).toBeGreaterThan(requestedNodeIndex) - expect(restoreIndex).toBeLessThan( - steps.findIndex((step) => step.name === 'Install dependencies') - ) - expect(steps[restoreIndex].uses).toBe('actions/cache/restore@v5') - expect(steps[restoreIndex].if).toBe( - "github.event_name == 'pull_request' && inputs.cache-pnpm-store != 'false' && !((runner.os == 'Linux' || runner.os == 'macOS') && (runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml') && (runner.os != 'Windows' || !(runner.arch == 'X64' && contains(inputs.cache-dependency-path, 'mobile/pnpm-lock.yaml')) && !((runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml'))" - ) - }) - - it('uses the repository package-manager version for every direct pnpm setup', () => { - const directSetups = globSync('.github/workflows/*.yml').flatMap((workflowPath) => { - const parsed = parse(readFileSync(workflowPath, 'utf8')) - return Object.values(parsed.jobs ?? {}).flatMap((job) => - (job.steps ?? []) - .filter((step) => step.uses === 'pnpm/setup@v2') - .map((step) => ({ workflowPath, step })) - ) - }) - - expect(directSetups.length).toBeGreaterThan(0) - for (const { workflowPath, step } of directSetups) { - expect(step.with?.version, workflowPath).toBeUndefined() - expect(step.with?.install, workflowPath).toBe(false) - } - }) - - it('restores Electron downloads before preparing the package runtime', () => { - const steps = workflow.jobs.package.steps - const cacheIndex = steps.findIndex((step) => step.name === 'Cache electron-builder downloads') - const installIndex = steps.findIndex( - (step) => step.uses === './.github/actions/install-node-dependencies' - ) - - expect(cacheIndex).toBeGreaterThanOrEqual(0) - expect(installIndex).toBeGreaterThanOrEqual(0) - expect(cacheIndex).toBeLessThan(installIndex) - }) - - it('prepares each native runtime before its consumers start', () => { - const installFor = (jobName) => - workflow.jobs[jobName].steps.find( - (step) => step.uses === './.github/actions/install-node-dependencies' - ) - const sharedTestInstall = unitTestWorkflow.jobs.test.steps.find( - (step) => step.uses === './.github/actions/install-node-dependencies' - ) - - for (const jobName of ['git_compatibility']) { - expect(installFor(jobName).with, jobName).toBeUndefined() - } - expect(installFor('xterm_patch_sync')).toBeUndefined() - expect(installFor('preflight').with['native-runtime']).toBe('node') - expect(installFor('shell_contracts').with['native-runtime']).toBe('node') - expect(sharedTestInstall.with['native-runtime']).toBe('node') - expect(installFor('package').with['native-runtime']).toBe('electron') - expect(installFor('package_windows').with['native-runtime']).toBe('node') - expect(installFor('package_windows').with['persist-native-cache']).toBe('false') - expect( - workflow.jobs.package_windows.steps.find( - (step) => step.name === 'Save compiled Node native modules' - ).if - ).toBe("steps.deps.outputs.native-cache-hit != 'true'") - - expect(dependencyAction.inputs['persist-native-cache'].default).toBe('true') - expect(nativeAction.runs.steps.find((step) => step.name === 'Use external node-gyp').if).toBe( - "runner.os == 'Linux' && inputs.native-runtime == 'node'" - ) - const dependencyInstall = dependencyAction.runs.steps.find( - (step) => step.name === 'Install dependencies' - ) - // Why frozen: re-resolving the graph costs a minute per job and the `git diff` - // guard below already fails the run when the lockfile is stale, so the slow - // resolution can never legitimately change anything. - expect(dependencyInstall.run).toContain('--frozen-lockfile') - expect(dependencyInstall.run).not.toContain('--no-frozen-lockfile') - expect(dependencyInstall.run).toContain( - 'git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml pnpm-workspace.yaml' - ) - expect(dependencyInstall.run).toContain('--ignore-scripts') - expect(dependencyInstall.run).not.toContain('--os=') - expect(dependencyInstall.run).not.toContain('--cpu=') - expect(pnpmWorkspace.supportedArchitectures.os).toEqual(['current']) - expect(pnpmWorkspace.supportedArchitectures.cpu).toEqual(['current']) - const prepareRuntime = dependencyAction.runs.steps.find( - (step) => step.name === 'Prepare native runtime' - ) - expect(prepareRuntime.if).toBe("inputs.native-runtime != 'none'") - expect(prepareRuntime.uses).toBe('./.github/actions/prepare-native-runtime') - expect(prepareRuntime.with).toEqual({ - 'native-runtime': '${{ inputs.native-runtime }}', - 'node-version': - '${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}', - 'persist-native-cache': '${{ inputs.persist-native-cache }}' - }) - }) - - it('reuses native preparation after the dependency action gate', () => { - const buildStep = workflow.jobs.package.steps.find( - (step) => step.name === 'Build package inputs' - ) - const packageStep = workflow.jobs.package.steps.find( - (step) => step.name === 'Package unpacked app' - ) - - expect(buildStep.run).not.toContain('ensure:electron-runtime') - expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1') - }) - - it('restores compiled native modules after the install that strips them', () => { - const installerSteps = dependencyAction.runs.steps - const installIndex = installerSteps.findIndex((step) => step.name === 'Install dependencies') - const nativeIndex = installerSteps.findIndex((step) => step.id === 'native-runtime') - expect(installIndex).toBeLessThan(nativeIndex) - const steps = nativeAction.runs.steps - const cacheIndex = steps.findIndex((step) => step.name === 'Restore compiled native modules') - const prepareIndex = steps.findIndex((step) => step.name === 'Prepare native runtime') - - // `--ignore-scripts` leaves no build/, so a restore before the install would be - // overwritten and one after the rebuild would never save a hit. - expect(cacheIndex).toBeLessThan(prepareIndex) - expect(steps[cacheIndex].if).toBe( - "inputs.native-runtime != 'none' && inputs.persist-native-cache != 'false'" - ) - const restoreOnly = steps.find( - (step) => step.name === 'Restore compiled native modules without saving' - ) - expect(restoreOnly.if).toBe( - "inputs.native-runtime != 'none' && inputs.persist-native-cache == 'false'" - ) - expect(restoreOnly.uses).toBe('actions/cache/restore@v5') - // Native artifacts are ABI-bound: a key missing either dimension serves a build - // that cannot load, and ensure-native-runtime would recompile it anyway. - for (const cacheStep of [steps[cacheIndex], restoreOnly]) { - expect(cacheStep.with.key).toBe('${{ steps.native-cache-scope.outputs.key }}') - expect(cacheStep.with.path).not.toContain('${{') - expect(cacheStep.with.path).toContain('node-pty@*/node_modules/node-pty/build') - expect(cacheStep.with.path).toContain('native/windows-registry/build') - expect(cacheStep.with.path).toContain('@vscode+windows-process-tre*') - expect(cacheStep.with['restore-keys']).toBeUndefined() - } - expect(steps[cacheIndex].id).toBe('native-cache-restore') - expect(restoreOnly.id).toBe('native-cache-restore-only') - const cacheScope = steps.find((step) => step.name === 'Resolve native cache scope') - expect(cacheScope.if).toBe("inputs.native-runtime != 'none'") - expect(cacheScope.run).toContain('/etc/os-release') - expect(nativeAction.outputs['cache-scope'].value).toBe( - '${{ steps.native-cache-scope.outputs.scope }}' - ) - expect(nativeAction.outputs['cache-hit'].value).toContain( - 'steps.native-cache-restore.outputs.cache-hit' - ) - expect(nativeAction.outputs['cache-hit'].value).toContain( - 'steps.native-cache-restore-only.outputs.cache-hit' - ) - for (const output of ['scope', 'hit', 'key', 'path']) { - expect(dependencyAction.outputs[`native-cache-${output}`].value).toBe( - `\${{ steps.native-runtime.outputs.cache-${output} }}` - ) - } - const electronCache = installerSteps.find( - (step) => step.name === 'Cache Electron package archive' - ) - const electronCacheResolution = installerSteps.find( - (step) => step.name === 'Resolve Electron package cache' - ) - expect(electronCacheResolution.if).toBe( - "inputs.native-runtime == 'electron' || inputs.cache-electron-package == 'true'" - ) - expect(electronCache.if).toBe( - "(github.event_name != 'pull_request' || runner.os != 'Linux') && steps.electron-package-cache.outputs.version != ''" - ) - expect(electronCache.uses).toBe('actions/cache@v5') - expect(electronCache.with.key).toContain('steps.electron-package-cache.outputs.version') - expect(dependencyAction.inputs['cache-electron-package'].default).toBe('false') - }) - - it('reuses TypeScript incremental state across typecheck runs', () => { - const steps = workflow.jobs.preflight.steps - const cacheIndex = steps.findIndex((step) => step.name === 'Cache TypeScript incremental state') - const checkIndex = steps.findIndex((step) => step.run === 'pnpm run typecheck') - - expect(cacheIndex).toBeGreaterThanOrEqual(0) - expect(cacheIndex).toBeLessThan(checkIndex) - expect(steps[cacheIndex].with.path).toBe('config/*.tsbuildinfo') - // Why restore-keys matter here: the base SHA key is shared by every commit in a PR, - // but actions/cache keeps the first successful graph until the base or config changes. - expect(steps[cacheIndex].with['restore-keys']).toBeTruthy() - // The buildinfo is only reusable while the compiler options that produced it hold. - expect(steps[cacheIndex].with.key).toContain( - "hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json')" - ) - expect(steps[cacheIndex].with.key).toContain('github.event.pull_request.base.sha') - expect(steps[cacheIndex].with['restore-keys']).not.toContain('tsbuildinfo-${{ runner.os }}-\n') - }) - - it('checks out full history without historical blobs', () => { - const fullHistoryCheckouts = Object.values(workflow.jobs) - .flatMap((job) => job.steps ?? []) - .filter( - (step) => step.uses?.startsWith('actions/checkout@') && step.with?.['fetch-depth'] === 0 - ) - - expect(fullHistoryCheckouts.length).toBeGreaterThan(0) - for (const checkout of fullHistoryCheckouts) { - expect(checkout.with.filter).toBe('blob:none') - } - }) - it('keeps advisory unit-selection evidence off the gate', () => { // It is continue-on-error, so it can never fail a PR. Living inside unit-tests.yml made a // caller's `needs: test` wait for it anyway, holding verify ~36s past the last shard. Pinned @@ -574,49 +45,4 @@ describe('PR workflow parallelism', () => { const job = evidenceWorkflow.jobs.selection_evidence expect(job['continue-on-error']).toBe(true) }) - - it('keeps verify as the aggregate required check', () => { - expect(workflow.jobs.verify.needs).toEqual([ - 'code_paths', - 'preflight', - 'git_compatibility', - 'codex_index_heal_contract', - 'xterm_patch_sync', - 'shell_contracts', - 'test', - 'orcad_browser', - 'mobile_web_app', - 'cross-version-wire', - 'managed_hook_node18', - 'package', - 'package_windows' - ]) - const verifyStep = workflow.jobs.verify.steps.find( - (step) => step.name === 'Require successful checks' - ) - expect(verifyStep.env.MANAGED_HOOK_NODE18).toBe('${{ needs.managed_hook_node18.result }}') - expect(verifyStep.run).toContain('"$MANAGED_HOOK_NODE18"') - // Why assert this one too: the browser provider test skips itself without - // ORCA_BROWSER_EXECUTABLE, so it only guards anything if verify actually reads it. - expect(verifyStep.env.ORCAD_BROWSER).toBe('${{ needs.orcad_browser.result }}') - expect(verifyStep.run).toContain('"$ORCAD_BROWSER"') - expect(verifyStep.env.CROSS_VERSION_WIRE).toBe('${{ needs.cross-version-wire.result }}') - expect(verifyStep.run).toContain('"$CROSS_VERSION_WIRE"') - // Same reason as the browser provider: the render check fails loudly on a runner with no - // Chrome, which only guards the page if verify reads the job's result. - expect(verifyStep.env.MOBILE_WEB_APP).toBe('${{ needs.mobile_web_app.result }}') - expect(verifyStep.run).toContain('"$MOBILE_WEB_APP"') - }) - - it('makes the mobile_web_app job refuse to skip the tests it exists to run', () => { - // The bundling tests skip themselves without mobile/node_modules, which is what keeps the - // sharded `test` job green. Only this env var stops that skip from spreading to the one job - // that installs them, so a typo here would leave the whole job passing vacuously. - const step = workflow.jobs.mobile_web_app.steps.find( - (entry) => entry.name === 'Builder, override census and render checks' - ) - expect(step.run).toContain('node config/scripts/run-mobile-web-app-checks.mjs') - expect(step.run).not.toContain('--prepare-route-snapshot') - expect(step.env[MOBILE_WEB_APP_DEPENDENCIES_REQUIRED_ENV]).toBe('1') - }) }) diff --git a/config/scripts/release-tag-pattern-census.test.mjs b/config/scripts/release-tag-pattern-census.test.mjs index b42a424a36f..36b1c148f49 100644 --- a/config/scripts/release-tag-pattern-census.test.mjs +++ b/config/scripts/release-tag-pattern-census.test.mjs @@ -1,23 +1,11 @@ -// Census: every release-triggered workflow and every script that lists this repo's releases -// classifies tags through release-tag-patterns.mjs, so a new tag family such as the agent state -// rules cannot reach a desktop-only path through one that forgot it. -import { readdirSync, readFileSync } from 'node:fs' -import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { parse } from 'yaml' import { DESKTOP_RC_TAG, DESKTOP_STABLE_TAG, - DESKTOP_STABLE_TAG_SHELL_PATTERN, agentStateRulesTag, isAgentStateRulesTag } from './release-tag-patterns.mjs' -const WORKFLOWS_DIR = '.github/workflows' -const SCRIPT_DIRS = ['config/scripts', '.github/scripts'] -const SHARED_IMPORT = /from '[^']*release-tag-patterns\.mjs'/ -const LISTS_RELEASES = /\/releases\?|listReleases|releases\.atom|'release',\s*'list'/ - const RULES_TAGS = [agentStateRulesTag(1, 'next'), agentStateRulesTag(1, 'stable')] const rulesRelease = (tag, extra = {}) => ({ tag_name: tag, @@ -29,51 +17,7 @@ const rulesRelease = (tag, extra = {}) => ({ ...extra }) -function read(path) { - return readFileSync(path, 'utf8') -} - -function importsSharedPatterns(path) { - return SHARED_IMPORT.test(read(path)) -} - -function isReleaseTriggered(workflow) { - const on = workflow.on - if (typeof on === 'string') { - return on === 'release' - } - if (Array.isArray(on)) { - return on.includes('release') - } - return typeof on === 'object' && on !== null && 'release' in on -} - -function releaseTriggeredWorkflows() { - return readdirSync(WORKFLOWS_DIR) - .filter((name) => /\.ya?ml$/.test(name)) - .map((name) => join(WORKFLOWS_DIR, name)) - .filter((path) => isReleaseTriggered(parse(read(path)))) -} - -function scriptsNamedIn(text) { - return [...text.matchAll(/((?:config|\.github)\/scripts\/[\w.-]+\.mjs)/g)].map( - (match) => match[1] - ) -} - -function releaseListingScripts() { - return SCRIPT_DIRS.flatMap((dir) => - readdirSync(dir) - .filter((name) => name.endsWith('.mjs') && !name.includes('.test.')) - .map((name) => join(dir, name)) - ).filter((path) => LISTS_RELEASES.test(read(path))) -} - -/** - * Scripts that list releases yet admit only desktop tags by their own shape. Each entry proves, - * by calling the script, that a rules release never passes; a new listing script must import the - * shared patterns or add a proof here. - */ +// Exercise the desktop consumers against rules releases. const DESKTOP_ONLY_PROOFS = { 'config/scripts/create-draft-release.mjs': async () => { const { latestPreviousPublishedDesktopReleaseTag } = await import('./create-draft-release.mjs') @@ -123,43 +67,9 @@ afterEach(() => { }) describe('release tag pattern census', () => { - it('finds the release-triggered workflows and release-listing scripts it guards', () => { - expect(releaseTriggeredWorkflows().length).toBeGreaterThan(0) - expect(releaseListingScripts().length).toBeGreaterThan(0) - }) - - it.each(releaseTriggeredWorkflows())('%s classifies tags through the shared patterns', (path) => { - const text = read(path) - const viaScript = scriptsNamedIn(text).some(importsSharedPatterns) - const viaShellPattern = text.includes(DESKTOP_STABLE_TAG_SHELL_PATTERN) - expect( - viaScript || viaShellPattern, - `${path} names no script importing release-tag-patterns.mjs and embeds no shared pattern` - ).toBe(true) - }) - - it.each(releaseTriggeredWorkflows())( - '%s checks out the shared patterns beside its script', - (path) => { - const workflow = parse(read(path)) - const sparse = Object.values(workflow.jobs) - .flatMap((job) => job.steps ?? []) - .map((step) => step.with?.['sparse-checkout']) - .filter((value) => typeof value === 'string') - for (const paths of sparse) { - if (scriptsNamedIn(paths).some(importsSharedPatterns)) { - expect(paths).toContain('config/scripts/release-tag-patterns.mjs') - } - } - } - ) - - it.each(releaseListingScripts())( + it.each(Object.keys(DESKTOP_ONLY_PROOFS))( '%s imports the shared patterns or proves it admits only desktop tags', async (path) => { - if (importsSharedPatterns(path)) { - return - } const proof = DESKTOP_ONLY_PROOFS[path] expect( proof, @@ -169,11 +79,6 @@ describe('release tag pattern census', () => { } ) - it('keeps no proof for a script that no longer lists releases', () => { - const listing = new Set(releaseListingScripts()) - expect(Object.keys(DESKTOP_ONLY_PROOFS).filter((path) => !listing.has(path))).toEqual([]) - }) - it('never classifies an agent state rules tag as a desktop release', () => { for (const tag of RULES_TAGS) { expect(isAgentStateRulesTag(tag)).toBe(true) diff --git a/config/scripts/ssh-hostile-hosts-workflow.test.mjs b/config/scripts/ssh-hostile-hosts-workflow.test.mjs deleted file mode 100644 index e64ccaf8390..00000000000 --- a/config/scripts/ssh-hostile-hosts-workflow.test.mjs +++ /dev/null @@ -1,120 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join, resolve } from 'node:path' -import { describe, expect, it } from 'vitest' -import { parse } from 'yaml' -import { NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts' -import { HOSTILE_HOST_CELLS } from '../../src/main/ssh/ssh-hostile-host-cells.ts' - -const projectDir = resolve(import.meta.dirname, '../..') -const readText = (name) => readFileSync(join(projectDir, '.github/workflows', name), 'utf8') -const workflow = parse(readText('ssh-hostile-hosts.yml')) - -function imageRefs(text, pattern) { - return [...new Set(text.match(pattern) ?? [])] -} - -describe('SSH hostile-host workflow', () => { - it('runs on demand and on path-filtered, non-draft pull requests only', () => { - expect(Object.keys(workflow.on).sort()).toEqual(['pull_request', 'workflow_dispatch']) - expect(workflow.on.pull_request.paths).toContain('src/main/ssh/ssh-relay-*') - for (const lane of ['glibc_slot', 'musl_slot', 'glibc217_slot']) { - expect(workflow.jobs[lane].if).toContain('github.event.pull_request.draft != true') - expect(workflow.jobs[lane].needs).toBeUndefined() - expect( - workflow.jobs[lane].steps.some((step) => - String(step.uses).startsWith('actions/download-artifact') - ) - ).toBe(false) - } - expect(workflow.jobs.hosts.needs).toEqual(['glibc_slot', 'musl_slot', 'glibc217_slot']) - }) - - // Why: the slots must come from the same builders the headless-server lanes qualify, so a - // NODE_RUNTIME_PIN or builder move cannot leave this matrix testing a stale runtime. - it('builds the slots on the headless-server lanes’ pinned builder images', () => { - const nodeServer = readText('node-server-tests.yml') - const hostile = readText('ssh-hostile-hosts.yml') - const alpine = /node:[0-9.]+-alpine@sha256:[0-9a-f]{64}/g - const manylinux = /quay\.io\/pypa\/manylinux_2_28_x86_64@sha256:[0-9a-f]{64}/g - expect(imageRefs(hostile, alpine)).toEqual(imageRefs(nodeServer, alpine)) - expect(imageRefs(hostile, alpine)).toEqual([ - expect.stringMatching(new RegExp(`^node:${NODE_RUNTIME_PIN.version.replaceAll('.', '\\.')}-`)) - ]) - expect(imageRefs(hostile, manylinux)).toEqual(imageRefs(nodeServer, manylinux)) - expect(imageRefs(hostile, manylinux)).toHaveLength(1) - const manylinux2014 = /quay\.io\/pypa\/manylinux2014_x86_64@sha256:[0-9a-f]{64}/g - expect(imageRefs(hostile, manylinux2014)).toEqual(imageRefs(nodeServer, manylinux2014)) - expect(imageRefs(hostile, manylinux2014)).toHaveLength(1) - }) - - // Why: the CentOS 7 cell expects rung B, which needs the compat slot in the hosts' template. - it('builds the glibc 2.17 compat slot and hands it to the hosts job', () => { - const compat = workflow.jobs.glibc217_slot.steps.map((step) => step.run ?? '').join('\n') - expect(compat).toContain('--slot=linux-x64-glibc217 --print-runtime') - expect(compat).toContain('--slot=linux-x64-glibc217 --smoke') - const artifactNames = ['glibc_slot', 'musl_slot', 'glibc217_slot'].map( - (lane) => - workflow.jobs[lane].steps.find((step) => - String(step.uses).startsWith('actions/upload-artifact') - ).with.name - ) - expect(artifactNames).toEqual([ - 'hostile-hosts-glibc-slot', - 'hostile-hosts-musl-slot', - 'hostile-hosts-glibc217-slot' - ]) - const download = workflow.jobs.hosts.steps.find((step) => - String(step.uses).startsWith('actions/download-artifact') - ) - expect(download.with.pattern).toBe('hostile-hosts-*-slot') - expect(download.with['merge-multiple']).not.toBe(true) - const merge = workflow.jobs.hosts.steps.find( - (step) => step.name === 'Merge verified Linux slots' - ) - expect(merge.run).toContain('node config/scripts/merge-orcad-prebuilds.mjs') - expect(merge.run).toContain('--require-slots linux-x64-glibc,linux-x64-musl,linux-x64-glibc217') - expect(workflow.jobs.hosts.steps.indexOf(merge)).toBeLessThan( - workflow.jobs.hosts.steps.findIndex( - (step) => step.name === 'Build the orcad template and relay' - ) - ) - }) - - it('opts the matrix in and runs it against both x64 Linux slots', () => { - const steps = workflow.jobs.hosts.steps - const matrix = steps.find((step) => step.name === 'Run the hostile-host matrix') - expect(matrix.env.ORCA_RUN_SSH_HOSTILE_HOSTS).toBe('1') - expect(matrix.run).toBe('pnpm test:node src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts') - expect(steps.map((step) => step.run ?? '').join('\n')).toContain( - '--targets linux-x64-glibc,linux-x64-musl' - ) - }) - - // Why: each macOS cell expects its runner's own slot, so the runner, template target and cell - // must agree or the cell would test a slot the template never packaged. - it('runs each macOS cell on the runner and template target it expects', () => { - const job = workflow.jobs.macos_hosts - expect(job.if).toContain('github.event.pull_request.draft != true') - expect(job.needs).toBeUndefined() - const runners = { 'darwin-arm64': 'macos-15', 'darwin-x64': 'macos-15-intel' } - const macCells = HOSTILE_HOST_CELLS.filter((cell) => cell.host === 'local-sshd') - expect( - job.strategy.matrix.include.map(({ os, target, cell }) => ({ os, target, cell })) - ).toEqual( - macCells.map((cell) => ({ - os: runners[cell.expect.target], - target: cell.expect.target, - cell: cell.id - })) - ) - const run = job.steps.map((step) => step.run ?? '').join('\n') - expect(run).toContain('--targets ${{ matrix.target }}') - expect(run).toContain('--require-slots ${{ matrix.target }}') - const cellStep = job.steps.find((step) => step.name === 'Run the macOS hostile-host cell') - expect(cellStep.env).toEqual({ - ORCA_RUN_SSH_HOSTILE_HOSTS: '1', - ORCA_SSH_HOSTILE_HOST_CELLS: '${{ matrix.cell }}' - }) - expect(cellStep.run).toBe('pnpm test:node src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts') - }) -}) diff --git a/config/scripts/verify-packaged-plugin-resources.test.mjs b/config/scripts/verify-packaged-plugin-resources.test.mjs index 79dda1f4866..1d443061b1b 100644 --- a/config/scripts/verify-packaged-plugin-resources.test.mjs +++ b/config/scripts/verify-packaged-plugin-resources.test.mjs @@ -43,13 +43,6 @@ describe('verify packaged plugin resources', () => { } }) - // The tree is hashed by raw bytes, so a CRLF checkout on Windows breaks the - // pinned hash. These two guard the `.gitattributes` eol=lf pin that prevents it. - it('pins the launch tree to LF so Windows checkouts hash identically', async () => { - const attributes = await readFile(join(process.cwd(), '.gitattributes'), 'utf8') - expect(attributes).toContain('/resources/plugins/** text eol=lf') - }) - it('rejects a CRLF checkout of the launch tree', async () => { const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-packaged-plugins-')) try { diff --git a/src/main/provider-process/provider-process-supervisor.integration.test.ts b/src/main/provider-process/provider-process-supervisor.integration.test.ts index 2d049dd2d21..98e03a679d5 100644 --- a/src/main/provider-process/provider-process-supervisor.integration.test.ts +++ b/src/main/provider-process/provider-process-supervisor.integration.test.ts @@ -314,7 +314,13 @@ describe.runIf(process.platform !== 'win32')('POSIX provider supervisor processe const signalledAt = Date.now() supervisor.kill('SIGTERM') - const exited = await Promise.race([exit, new Promise((resolve) => setTimeout(resolve, 5_000))]) + let exitTimeout: ReturnType | undefined + const exited = await Promise.race([ + exit, + new Promise((resolve) => { + exitTimeout = setTimeout(resolve, 5_000) + }) + ]).finally(() => clearTimeout(exitTimeout)) expect(exited).toEqual({ code: null, signal: 'SIGTERM' }) expect(Date.now() - signalledAt).toBeGreaterThanOrEqual(graceMs) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.live-reasoning.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.live-reasoning.test.tsx index 505c87d763e..70dc20f30f3 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.live-reasoning.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.live-reasoning.test.tsx @@ -2,307 +2,846 @@ import '@testing-library/jest-dom/vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' -import type { NativeChatLiveSession } from './use-native-chat-live-session' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalStatusItem +} from '../../../../shared/agent-session-journal-types' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { projectStructuredItemToNativeChat } from '../../../../shared/structured-agent-session-projection' import { NativeChatMessageList } from './NativeChatMessageList' import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' -import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' -import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { projectNativeChatTaskListFrames } from './native-chat-task-list-frames' +import { openToolRunMembers } from './native-chat-tool-run-members-test-support' +import { projectStructuredQuestionMessages } from './structured-agent-question-projection' +import type { NativeChatLiveSession } from './use-native-chat-live-session' -let restoreViewport = (): void => {} -beforeAll(() => { - restoreViewport = installNativeChatMessageListTestViewport() -}) -afterAll(() => restoreViewport()) -afterEach(cleanup) +{ + const STARTED = 1_000 -const STARTED = 1_000 - -const prompt: NativeChatMessage = { - id: 'user-1', - role: 'user', - blocks: [{ type: 'text', text: 'Start the task' }], - timestamp: STARTED - 500, - source: 'transcript' -} - -function reasoning( - id: string, - text: string, - state: 'running' | 'completed', - fields: Partial = {} -): NativeChatMessage { - return { - id, - role: 'reasoning', - blocks: [{ type: 'text', text }], - timestamp: STARTED, - source: 'transcript', - state, - ...(state === 'completed' ? { completedAt: STARTED + 12_000 } : {}), - ...fields + const prompt: NativeChatMessage = { + id: 'user-1', + role: 'user', + blocks: [{ type: 'text', text: 'Start the task' }], + timestamp: STARTED - 500, + source: 'transcript' } -} -/** The journal that says the turn runs and what its newest content is. */ -function journal(rows: readonly NativeChatMessage[]): AgentJournalRenderItem[] { - return [ - { - itemId: prompt.id, - revision: 1, - sequence: 1, - observedAt: 1, - body: { kind: 'message', role: 'user', blocks: prompt.blocks } - }, - { - itemId: 'turn-1', - revision: 1, - sequence: 2, - observedAt: 2, - body: { kind: 'turn', turnId: 'turn-1', state: 'running', userItemId: prompt.id } - }, - ...rows.map((row, index) => ({ - itemId: row.id, - revision: 1, - sequence: index + 3, - observedAt: index + 3, - ...(row.agentId ? { agentId: row.agentId } : {}), - body: { - kind: 'message' as const, - role: row.role, - blocks: row.blocks, - ...(row.state ? { state: row.state } : {}) - } - })) - ] -} - -function sessionOf(messages: NativeChatMessage[]): NativeChatLiveSession { - return { - messages, - status: 'working', - sessionId: 'session-1', - agent: 'claude', - hasMore: false, - loadingEarlier: false, - olderHistoryGeneration: 0, - loadEarlier: vi.fn(), - readPhase: 'ready' - } -} - -function list( - rows: readonly NativeChatMessage[], - props: Partial> = {} -): React.JSX.Element { - return ( - - ) -} - -const liveLine = (): HTMLElement => - screen.getByText('Thinking').closest('[data-native-chat-turn-activity]')! - -describe('live reasoning, read through the one live line', () => { - it('shows one "Thinking", collapsed, and no row for the open block', () => { - render(list([reasoning('r-1', 'Weighing two approaches', 'running')])) - expect(screen.getAllByText('Thinking')).toHaveLength(1) - const toggle = screen.getByRole('button', { name: 'Thinking' }) - expect(toggle).toHaveAttribute('aria-expanded', 'false') - expect(liveLine()).toContainElement(toggle) - expect(screen.queryByRole('button', { name: /Reasoning|Thought/ })).toBeNull() - expect(screen.queryByText('Weighing two approaches')).toBeNull() - }) - - it('opens to the live text, which follows the block as it grows', () => { - const { rerender } = render(list([reasoning('r-1', 'Weighing two approaches', 'running')])) - fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) - expect(screen.getByRole('button', { name: 'Thinking' })).toHaveAttribute( - 'aria-expanded', - 'true' - ) - expect(screen.getByText('Weighing two approaches')).toBeInTheDocument() - rerender(list([reasoning('r-1', 'Weighing two approaches, then the cheaper one', 'running')])) - expect(screen.getByText('Weighing two approaches, then the cheaper one')).toBeInTheDocument() - }) - - it('keeps the body out of the live region, which announces the label only', () => { - render(list([reasoning('r-1', 'Weighing two approaches', 'running')])) - fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) - const body = screen.getByText('Weighing two approaches') - expect(body.closest('[aria-live]')).toBeNull() - expect(screen.getByText('Thinking').closest('[aria-live]')).not.toBeNull() - }) - - it('lands the finished row open when the reader opened it live, while the turn works on', () => { - const { rerender } = render(list([reasoning('r-1', 'Weighing two approaches', 'running')])) - fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) - rerender(list([reasoning('r-1', 'Weighing two approaches', 'completed')])) - // The line no longer discloses anything; the row does, still open. - expect(screen.queryByRole('button', { name: /Thinking|Working/ })).toBeNull() - expect(screen.getByRole('button', { name: 'Reasoning: Thought for 12s' })).toHaveAttribute( - 'aria-expanded', - 'true' - ) - expect(screen.getByText('Weighing two approaches')).toBeInTheDocument() - }) - - // The body owns its colour: inherited, it read full foreground under the line and muted in the row, - // so the text dimmed as the block landed. - it('draws the same body, in its own quieter tone, live and once landed', () => { - const body = () => - screen.getByText('Weighing two approaches').closest('[data-native-chat-message-tone]') - const { rerender } = render(list([reasoning('r-1', 'Weighing two approaches', 'running')])) - fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) - const live = body() - expect(live).toHaveAttribute('data-native-chat-message-tone', 'faint') - expect(live).toHaveClass('text-chat-foreground-faint', 'pl-5.5', 'max-h-80') - const liveClasses = live?.getAttribute('class') - rerender(list([reasoning('r-1', 'Weighing two approaches', 'completed')])) - expect(body()?.getAttribute('class')).toBe(liveClasses) - }) - - it('starts the next block collapsed', () => { - const { rerender } = render(list([reasoning('r-1', 'First thought', 'running')])) - fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) - rerender( - list([reasoning('r-1', 'First thought', 'completed'), reasoning('r-2', 'Second', 'running')]) - ) - expect(screen.getByRole('button', { name: 'Thinking' })).toHaveAttribute( - 'aria-expanded', - 'false' - ) - expect(screen.queryByText('Second')).toBeNull() - }) - - it('is not expandable while the open block has no text yet', () => { - render(list([reasoning('r-1', '', 'running')])) - expect(screen.getAllByText('Thinking')).toHaveLength(1) - expect(screen.queryByRole('button', { name: 'Thinking' })).toBeNull() - }) - - it('draws the open row when a waiting prompt replaces the line', () => { - render( - list([reasoning('r-1', 'Weighing two approaches', 'running')], { - awaitingInput: 'unshown' - }) - ) - expect(screen.queryByText('Thinking')).toBeNull() - expect(screen.getByRole('button', { name: 'Reasoning' })).toHaveAttribute( - 'aria-expanded', - 'false' - ) - }) - - // The open block can sit on a slot kept for its turn's bar or diff rollup; only the slot stays. - it('draws no row for the open block under a diff rollup on its turn', () => { - const edit: NativeChatMessage = { - id: 'edit-1', - role: 'assistant', - blocks: [ - { type: 'tool-call', name: 'Diff', input: { path: 'a.ts' }, state: 'completed' }, - { type: 'tool-result', output: '@@ -1 +1 @@\n-old\n+new' } - ], + function reasoning( + id: string, + text: string, + state: 'running' | 'completed', + fields: Partial = {} + ): NativeChatMessage { + return { + id, + role: 'reasoning', + blocks: [{ type: 'text', text }], timestamp: STARTED, - source: 'transcript' + source: 'transcript', + state, + ...(state === 'completed' ? { completedAt: STARTED + 12_000 } : {}), + ...fields } - render( - list([ - edit, - reasoning('r-1', 'Weighing two approaches', 'running', { timestamp: STARTED + 50 }) - ]) - ) - expect(screen.queryByRole('button', { name: /Reasoning/ })).toBeNull() - fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) - expect(screen.getAllByText('Weighing two approaches')).toHaveLength(1) - }) + } - it('draws no row for the open block that carries a provider-opened turn bar', () => { - const asked: NativeChatMessage = { ...prompt, id: 'u1', timestamp: 1 } - const done: NativeChatMessage = { - id: 'a1', - role: 'assistant', - blocks: [{ type: 'text', text: 'Done.' }], - timestamp: 2, - source: 'transcript' + /** The journal that says the turn runs and what its newest content is. */ + function journal(rows: readonly NativeChatMessage[]): AgentJournalRenderItem[] { + return [ + { + itemId: prompt.id, + revision: 1, + sequence: 1, + observedAt: 1, + body: { kind: 'message', role: 'user', blocks: prompt.blocks } + }, + { + itemId: 'turn-1', + revision: 1, + sequence: 2, + observedAt: 2, + body: { kind: 'turn', turnId: 'turn-1', state: 'running', userItemId: prompt.id } + }, + ...rows.map((row, index) => ({ + itemId: row.id, + revision: 1, + sequence: index + 3, + observedAt: index + 3, + ...(row.agentId ? { agentId: row.agentId } : {}), + body: { + kind: 'message' as const, + role: row.role, + blocks: row.blocks, + ...(row.state ? { state: row.state } : {}) + } + })) + ] + } + + function sessionOf(messages: NativeChatMessage[]): NativeChatLiveSession { + return { + messages, + status: 'working', + sessionId: 'session-1', + agent: 'claude', + hasMore: false, + loadingEarlier: false, + olderHistoryGeneration: 0, + loadEarlier: vi.fn(), + readPhase: 'ready' } - const woke = reasoning('r-w', 'Checking the background build', 'running', { timestamp: 3 }) - const thread = { kind: 'thread' as const } - const inTurn = (turnItemId: string) => ({ kind: 'turn' as const, turnItemId }) - const item = ( - itemId: string, - sequence: number, - body: AgentJournalRenderItem['body'], - turnScope: AgentJournalRenderItem['turnScope'] - ): AgentJournalRenderItem => ({ - itemId, - revision: 0, - sequence, - observedAt: sequence, - turnScope, + } + + function list( + rows: readonly NativeChatMessage[], + props: Partial> = {} + ): React.JSX.Element { + return ( + + ) + } + + const liveLine = (): HTMLElement => + screen.getByText('Thinking').closest('[data-native-chat-turn-activity]')! + + describe('live reasoning, read through the one live line', () => { + let restoreViewport = (): void => {} + beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() + }) + afterAll(() => restoreViewport()) + afterEach(cleanup) + it('shows one "Thinking", collapsed, and no row for the open block', () => { + render(list([reasoning('r-1', 'Weighing two approaches', 'running')])) + expect(screen.getAllByText('Thinking')).toHaveLength(1) + const toggle = screen.getByRole('button', { name: 'Thinking' }) + expect(toggle).toHaveAttribute('aria-expanded', 'false') + expect(liveLine()).toContainElement(toggle) + expect(screen.queryByRole('button', { name: /Reasoning|Thought/ })).toBeNull() + expect(screen.queryByText('Weighing two approaches')).toBeNull() + }) + + it('opens to the live text, which follows the block as it grows', () => { + const { rerender } = render(list([reasoning('r-1', 'Weighing two approaches', 'running')])) + fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) + expect(screen.getByRole('button', { name: 'Thinking' })).toHaveAttribute( + 'aria-expanded', + 'true' + ) + expect(screen.getByText('Weighing two approaches')).toBeInTheDocument() + rerender(list([reasoning('r-1', 'Weighing two approaches, then the cheaper one', 'running')])) + expect(screen.getByText('Weighing two approaches, then the cheaper one')).toBeInTheDocument() + }) + + it('keeps the body out of the live region, which announces the label only', () => { + render(list([reasoning('r-1', 'Weighing two approaches', 'running')])) + fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) + const body = screen.getByText('Weighing two approaches') + expect(body.closest('[aria-live]')).toBeNull() + expect(screen.getByText('Thinking').closest('[aria-live]')).not.toBeNull() + }) + + it('lands the finished row open when the reader opened it live, while the turn works on', () => { + const { rerender } = render(list([reasoning('r-1', 'Weighing two approaches', 'running')])) + fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) + rerender(list([reasoning('r-1', 'Weighing two approaches', 'completed')])) + // The line no longer discloses anything; the row does, still open. + expect(screen.queryByRole('button', { name: /Thinking|Working/ })).toBeNull() + expect(screen.getByRole('button', { name: 'Reasoning: Thought for 12s' })).toHaveAttribute( + 'aria-expanded', + 'true' + ) + expect(screen.getByText('Weighing two approaches')).toBeInTheDocument() + }) + + // The body owns its colour: inherited, it read full foreground under the line and muted in the row, + // so the text dimmed as the block landed. + it('draws the same body, in its own quieter tone, live and once landed', () => { + const body = () => + screen.getByText('Weighing two approaches').closest('[data-native-chat-message-tone]') + const { rerender } = render(list([reasoning('r-1', 'Weighing two approaches', 'running')])) + fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) + const live = body() + expect(live).toHaveAttribute('data-native-chat-message-tone', 'faint') + expect(live).toHaveClass('text-chat-foreground-faint', 'pl-5.5', 'max-h-80') + const liveClasses = live?.getAttribute('class') + rerender(list([reasoning('r-1', 'Weighing two approaches', 'completed')])) + expect(body()?.getAttribute('class')).toBe(liveClasses) + }) + + it('starts the next block collapsed', () => { + const { rerender } = render(list([reasoning('r-1', 'First thought', 'running')])) + fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) + rerender( + list([ + reasoning('r-1', 'First thought', 'completed'), + reasoning('r-2', 'Second', 'running') + ]) + ) + expect(screen.getByRole('button', { name: 'Thinking' })).toHaveAttribute( + 'aria-expanded', + 'false' + ) + expect(screen.queryByText('Second')).toBeNull() + }) + + it('is not expandable while the open block has no text yet', () => { + render(list([reasoning('r-1', '', 'running')])) + expect(screen.getAllByText('Thinking')).toHaveLength(1) + expect(screen.queryByRole('button', { name: 'Thinking' })).toBeNull() + }) + + it('draws the open row when a waiting prompt replaces the line', () => { + render( + list([reasoning('r-1', 'Weighing two approaches', 'running')], { + awaitingInput: 'unshown' + }) + ) + expect(screen.queryByText('Thinking')).toBeNull() + expect(screen.getByRole('button', { name: 'Reasoning' })).toHaveAttribute( + 'aria-expanded', + 'false' + ) + }) + + // The open block can sit on a slot kept for its turn's bar or diff rollup; only the slot stays. + it('draws no row for the open block under a diff rollup on its turn', () => { + const edit: NativeChatMessage = { + id: 'edit-1', + role: 'assistant', + blocks: [ + { type: 'tool-call', name: 'Diff', input: { path: 'a.ts' }, state: 'completed' }, + { type: 'tool-result', output: '@@ -1 +1 @@\n-old\n+new' } + ], + timestamp: STARTED, + source: 'transcript' + } + render( + list([ + edit, + reasoning('r-1', 'Weighing two approaches', 'running', { timestamp: STARTED + 50 }) + ]) + ) + expect(screen.queryByRole('button', { name: /Reasoning/ })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) + expect(screen.getAllByText('Weighing two approaches')).toHaveLength(1) + }) + + it('draws no row for the open block that carries a provider-opened turn bar', () => { + const asked: NativeChatMessage = { ...prompt, id: 'u1', timestamp: 1 } + const done: NativeChatMessage = { + id: 'a1', + role: 'assistant', + blocks: [{ type: 'text', text: 'Done.' }], + timestamp: 2, + source: 'transcript' + } + const woke = reasoning('r-w', 'Checking the background build', 'running', { timestamp: 3 }) + const thread = { kind: 'thread' as const } + const inTurn = (turnItemId: string) => ({ kind: 'turn' as const, turnItemId }) + const item = ( + itemId: string, + sequence: number, + body: AgentJournalRenderItem['body'], + turnScope: AgentJournalRenderItem['turnScope'] + ): AgentJournalRenderItem => ({ + itemId, + revision: 0, + sequence, + observedAt: sequence, + turnScope, + body + }) + const items = [ + item('u1', 1, { kind: 'message', role: 'user', blocks: asked.blocks }, thread), + item('t1', 2, { kind: 'turn', turnId: 't1', state: 'completed', userItemId: 'u1' }, thread), + item('a1', 3, { kind: 'message', role: 'assistant', blocks: done.blocks }, inTurn('t1')), + item( + 'wake', + 4, + { kind: 'turn', turnId: 'wake', state: 'running', userItemId: 'claude:wake' }, + thread + ), + item( + 'r-w', + 5, + { kind: 'message', role: 'reasoning', blocks: woke.blocks, state: 'running' }, + inTurn('wake') + ) + ] + render(list([], { session: sessionOf([asked, done, woke]), journalItems: items })) + expect(screen.getByText(/Working for/)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Reasoning/ })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) + expect(screen.getAllByText('Checking the background build')).toHaveLength(1) + }) + + // A live region only announces changes to itself; a replaced one says nothing. + it('keeps one live region while the line turns into the disclosure and back', () => { + const tool: NativeChatMessage = { + id: 'tool-1', + role: 'assistant', + blocks: [ + { type: 'tool-call', name: 'shell', input: { command: 'ls' }, state: 'completed' } + ], + timestamp: STARTED, + source: 'transcript' + } + const later = { timestamp: STARTED + 50 } + const region = () => document.querySelector('[data-native-chat-turn-activity][aria-live]') + const { rerender } = render(list([tool])) + const before = region() + expect(before).toHaveTextContent('Working…') + rerender(list([tool, reasoning('r-1', 'Weighing two approaches', 'running', later)])) + expect(region()).toBe(before) + expect(before).toHaveTextContent('Thinking') + rerender( + list([ + tool, + reasoning('r-1', 'Weighing two approaches', 'completed', later), + { ...tool, id: 'tool-2', timestamp: STARTED + 60 } + ]) + ) + expect(region()).toBe(before) + expect(before).toHaveTextContent('Working…') + }) + }) +} + +{ + function frame( + id: number, + status: string, + overrides: { kind?: string; truncated?: boolean } = {} + ) { + const kind = overrides.kind ?? 'notification:turn/plan/updated' + const head = JSON.stringify({ + threadId: 'thread', + turnId: 'turn', + explanation: 'Keep verification visible', + plan: [{ step: 'Verify', status }] + }) + const body: AgentJournalStatusItem = { + kind: 'status', + text: `codex · ${kind}`, + providerFrame: { + provider: 'codex', + kind, + payload: { + head, + byteLength: new TextEncoder().encode(head).byteLength, + digest: 'fixture-digest', + truncated: overrides.truncated ?? false + } + } + } + const message = projectStructuredItemToNativeChat({ + itemId: `frame-${id}`, + revision: 1, + sequence: id, + observedAt: id, body }) - const items = [ - item('u1', 1, { kind: 'message', role: 'user', blocks: asked.blocks }, thread), - item('t1', 2, { kind: 'turn', turnId: 't1', state: 'completed', userItemId: 'u1' }, thread), - item('a1', 3, { kind: 'message', role: 'assistant', blocks: done.blocks }, inTurn('t1')), - item( - 'wake', - 4, - { kind: 'turn', turnId: 'wake', state: 'running', userItemId: 'claude:wake' }, - thread - ), - item( - 'r-w', - 5, - { kind: 'message', role: 'reasoning', blocks: woke.blocks, state: 'running' }, - inTurn('wake') - ) - ] - render( - list([], { - session: sessionOf([asked, done, woke]), - journalItems: items - }) + if (!message) { + throw new Error('Expected a projected message') + } + return message + } + + function transcript(messages: NativeChatMessage[], sessionId = 'live-codex') { + return ( + ) - expect(screen.getByText(/Working for/)).toBeInTheDocument() - expect(screen.queryByRole('button', { name: /Reasoning/ })).toBeNull() - fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) - expect(screen.getAllByText('Checking the background build')).toHaveLength(1) + } + + describe('live Codex checklist frames', () => { + let restoreViewport = (): void => {} + beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() + }) + afterAll(() => restoreViewport()) + afterEach(cleanup) + it('updates one pinned checklist from journal notifications without rewinding on pagination', () => { + const first = frame(1, 'pending') + const active = frame(2, 'inProgress') + const last = frame(3, 'completed') + const { rerender, container } = render(transcript([first])) + const toggle = screen.getByRole('button', { name: 'Tasks 0 of 1 tasks completed' }) + const viewport = container.querySelector('.overflow-y-auto')! + expect(viewport.contains(toggle)).toBe(false) + fireEvent.click(toggle) + rerender(transcript([first, active])) + expect(within(toggle.parentElement!).getByText('Verify').closest('li')).toHaveClass( + 'text-foreground' + ) + expect(within(viewport as HTMLElement).getByText('Started Verify')).toBeInTheDocument() + rerender(transcript([last])) + expect( + within( + screen.getByRole('button', { name: 'Tasks 1 of 1 tasks completed' }).parentElement! + ).getByText('Verify') + ).toHaveClass('line-through') + expect(screen.getAllByText('Keep verification visible')).toHaveLength(2) + rerender(transcript([first, active, last])) + expect(screen.getAllByText('Verify')).toHaveLength(2) + expect(screen.getByRole('button', { name: 'Tasks 1 of 1 tasks completed' })).toBe(toggle) + expect(screen.getByText('Started Verify')).toBeInTheDocument() + expect(screen.queryByText('notification:turn/plan/updated')).toBeNull() + expect(projectNativeChatTaskListFrames([last])[0]).toBe( + projectNativeChatTaskListFrames([last])[0] + ) + }) + + it('uses the latest complete snapshot across Codex tool calls and notifications', () => { + const tool: NativeChatMessage = { + id: 'tool', + role: 'assistant', + timestamp: 1, + source: 'transcript', + // Journalled like the frames it sits between. + journalPosition: { sequence: 1, index: 0 }, + blocks: [ + { + type: 'tool-call', + name: 'update_plan', + input: { plan: [{ step: 'Verify', status: 'pending' }] } + } + ] + } + render(transcript([tool, frame(3, 'completed')])) + fireEvent.click(screen.getByRole('button', { name: 'Tasks 1 of 1 tasks completed' })) + expect(screen.getAllByText('Verify')).toHaveLength(2) + expect( + within( + screen.getByRole('button', { name: 'Tasks 1 of 1 tasks completed' }).parentElement! + ).getByText('Verify') + ).toHaveClass('line-through') + }) + + it('keeps malformed, truncated, other-provider, and plan-document frames unchanged', () => { + const truncated = frame(1, 'pending', { truncated: true }) + const document = frame(2, 'pending', { kind: 'item:plan' }) + const malformed = frame(3, 'pending') + const otherProvider = frame(4, 'pending') + const malformedBlock = malformed.blocks[0] + const otherBlock = otherProvider.blocks[0] + if (malformedBlock.type === 'text' && malformedBlock.providerFrame) { + malformedBlock.providerFrame.payload.head = '{"plan":null}' + } + if (otherBlock.type === 'text' && otherBlock.providerFrame) { + otherBlock.providerFrame.provider = 'claude' + } + const messages = [truncated, document, malformed, otherProvider] + const projected = projectNativeChatTaskListFrames(messages) + projected.forEach((message, index) => expect(message).toBe(messages[index])) + render(transcript([truncated])) + // Unreadable as a list and wordless, so it is neither a checklist nor a raw row. + expect(screen.queryByText('notification:turn/plan/updated')).toBeNull() + expect(screen.queryByText('Tasks')).toBeNull() + }) + + // Stored for readers like the task list, drawn only when it has words of its own. + it('hides a wordless unrecognised frame while a plan update still becomes the checklist', () => { + const unknown = frame(1, 'pending', { kind: 'notification:future/event' }) + const failure = frame(2, 'pending', { kind: 'notification:future/failure' }) + const failureBlock = failure.blocks[0] + if (failureBlock.type === 'text') { + failureBlock.tone = 'error' + } + render(transcript([unknown, failure, frame(3, 'inProgress')])) + expect(screen.queryByText('notification:future/event')).toBeNull() + expect(screen.getByText('codex · notification:future/failure')).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Tasks 0 of 1 tasks completed' }) + ).toBeInTheDocument() + }) + + it('does not consume a neighboring tool failure as a notification result', () => { + const command: NativeChatMessage = { + id: 'command', + role: 'assistant', + timestamp: 2, + source: 'transcript', + blocks: [ + { type: 'tool-call', name: 'shell', input: { command: 'verify' }, state: 'failed' }, + { type: 'tool-result', output: 'Verification failed', isError: true } + ] + } + render(transcript([frame(1, 'pending'), command])) + expect( + screen.getByRole('button', { name: 'Tasks 0 of 1 tasks completed' }) + ).toBeInTheDocument() + openToolRunMembers() + expect(screen.getByText('Verification failed', { selector: 'pre' })).toHaveClass( + 'text-destructive' + ) + }) }) - // A live region only announces changes to itself; a replaced one says nothing. - it('keeps one live region while the line turns into the disclosure and back', () => { - const tool: NativeChatMessage = { - id: 'tool-1', - role: 'assistant', - blocks: [{ type: 'tool-call', name: 'shell', input: { command: 'ls' }, state: 'completed' }], - timestamp: STARTED, - source: 'transcript' - } - const later = { timestamp: STARTED + 50 } - const region = () => document.querySelector('[data-native-chat-turn-activity][aria-live]') - const { rerender } = render(list([tool])) - const before = region() - expect(before).toHaveTextContent('Working…') - rerender(list([tool, reasoning('r-1', 'Weighing two approaches', 'running', later)])) - expect(region()).toBe(before) - expect(before).toHaveTextContent('Thinking') - rerender( - list([ - tool, - reasoning('r-1', 'Weighing two approaches', 'completed', later), - { ...tool, id: 'tool-2', timestamp: STARTED + 60 } - ]) - ) - expect(region()).toBe(before) - expect(before).toHaveTextContent('Working…') + describe('NativeChatMessageList task list history', () => { + let restoreViewport = (): void => {} + beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() + }) + afterAll(() => restoreViewport()) + afterEach(cleanup) + it('keeps the latest Claude state after pagination and resets disclosure between sessions', () => { + const first = { + id: 'first-list', + role: 'assistant' as const, + timestamp: 1, + source: 'transcript' as const, + blocks: [ + { + type: 'tool-call' as const, + name: 'TodoWrite', + input: { + todos: [ + { content: 'Read', status: 'pending' }, + { content: 'Test', status: 'pending' } + ] + } + } + ] + } + const last = { + ...first, + id: 'last-list', + timestamp: 3, + blocks: [ + { type: 'text' as const, text: 'Ready for verification' }, + { + type: 'tool-call' as const, + name: 'TodoWrite', + input: { + todos: [ + { content: 'Read', status: 'completed' }, + { content: 'Test', status: 'pending' } + ] + } + } + ] + } + const { rerender } = render(transcript([last])) + fireEvent.click(screen.getByRole('button', { name: 'Tasks 1 of 2 tasks completed' })) + rerender(transcript([first, last])) + expect(screen.getAllByText('Read')).toHaveLength(2) + expect(screen.getByText('Completed Read')).toBeInTheDocument() + expect( + within( + screen.getByRole('button', { name: 'Tasks 1 of 2 tasks completed' }).parentElement! + ).getByText('Read') + ).toHaveClass('line-through') + expect(screen.getByText('Ready for verification')).toBeInTheDocument() + rerender(transcript([first], 'two')) + expect(screen.getByRole('button', { name: 'Tasks 0 of 2 tasks completed' })).toHaveAttribute( + 'aria-expanded', + 'false' + ) + expect(screen.getAllByText('Read')).toHaveLength(1) + rerender(transcript([], 'three')) + expect(screen.queryByText('Tasks')).toBeNull() + }) }) -}) +} + +{ + const scrollTo = vi.fn() + + function item( + itemId: string, + body: AgentJournalItemBody, + sequence: number + ): AgentJournalRenderItem { + return { itemId, body, sequence, observedAt: sequence * 1000, revision: 1 } + } + const user = item( + 'user', + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Make the change' }] }, + 1 + ) + const prose = item( + 'prose', + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Updating the files.' }] }, + 2 + ) + function diff(patch = '@@ -1 +1 @@\n-before\n+after'): AgentJournalRenderItem { + return item( + 'diff', + { + kind: 'diff', + path: 'src/a.ts', + patch: { head: patch, truncated: false, digest: 'fixture', byteLength: patch.length } + }, + 3 + ) + } + function session(items: AgentJournalRenderItem[]): NativeChatLiveSession { + return { + messages: projectStructuredQuestionMessages(items), + status: 'ready', + sessionId: 'session', + agent: 'codex', + hasMore: false, + loadingEarlier: false, + olderHistoryGeneration: 0, + loadEarlier: vi.fn(), + readPhase: 'ready' + } + } + function view(items: AgentJournalRenderItem[], structured = true) { + return ( + + ) + } + + describe('turn history presentation', () => { + let restoreViewport = (): void => {} + beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() + }) + afterAll(() => restoreViewport()) + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + it('renders canonical pending questions while idle and keeps resolved answers at their row', () => { + const question = item( + 'question', + { + kind: 'question', + question: 'Which branch?', + options: [{ id: 'main', label: 'main' }], + resolution: { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + }, + 3 + ) + const { rerender } = render(view([user, prose, question])) + expect(screen.getByText('Awaiting user input:')).toBeInTheDocument() + expect(screen.getByText('Which branch?')).toBeInTheDocument() + expect(screen.queryByText(/request_user_input/)).toBeNull() + const settled = item( + 'question', + { + ...question.body, + kind: 'question', + question: 'Which branch?', + options: [{ id: 'main', label: 'main' }], + resolution: { + state: 'resolved', + selectedOptionId: 'main', + resolvedBy: 'desktop', + resolvedAt: 4000 + } + }, + 3 + ) + rerender(view([user, prose, settled])) + expect(screen.queryByText('Awaiting user input:')).toBeNull() + expect(screen.getByText('Asked:')).toBeInTheDocument() + expect(screen.getByText('main')).toBeInTheDocument() + }) + + it('renders one resolved row when Claude journals both the call and receipt', () => { + const call = item( + 'ask-call', + { + kind: 'tool-call', + name: 'AskUserQuestion', + input: { questions: [{ question: 'Which branch?' }] }, + state: 'completed', + output: { head: 'main', byteLength: 4, truncated: false, digest: 'answer' } + }, + 3 + ) + const question = item( + 'question-receipt', + { + kind: 'question', + question: 'Which branch?', + options: [{ id: 'main', label: 'main' }], + resolution: { + state: 'resolved', + selectedOptionId: 'main', + resolvedBy: 'desktop', + resolvedAt: 4000 + } + }, + 4 + ) + + render(view([user, call, question])) + + expect(screen.getAllByText('Asked:')).toHaveLength(1) + expect(screen.queryByText(/AskUserQuestion/)).toBeNull() + }) + + it('groups pending Codex questions then narrows the awaiting count after one answer', () => { + const first = item( + 'q1', + { + kind: 'question', + question: 'Which branch?', + options: [{ id: 'main', label: 'main' }], + resolution: { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + }, + 3 + ) + const second = item( + 'q2', + { + kind: 'question', + question: 'Proceed?', + options: [], + resolution: { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + }, + 4 + ) + const { rerender } = render(view([user, first, second])) + expect(screen.getByText('2 questions')).toBeInTheDocument() + expect(screen.getAllByText('Awaiting user input:')).toHaveLength(1) + const answered = item( + 'q1', + { + kind: 'question', + question: 'Which branch?', + options: [{ id: 'main', label: 'main' }], + resolution: { + state: 'resolved', + selectedOptionId: 'main', + resolvedBy: 'phone', + resolvedAt: 5000 + } + }, + 3 + ) + rerender(view([user, answered, second])) + expect(screen.queryByText('2 questions')).toBeNull() + expect(screen.getByText('Proceed?')).toBeInTheDocument() + expect(screen.getByText('main')).toBeInTheDocument() + expect(screen.getAllByText('Awaiting user input:')).toHaveLength(1) + }) + + it('reveals and scrolls to a folded diff card from a collapsed completed turn', () => { + vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo) + render(view([user, prose, diff()])) + expect(screen.queryByRole('button', { name: /^Edited .*a\.ts(?:\s|$)/ })).toBeNull() + const header = screen.getByRole('button', { name: /1 changed file/ }) + expect(header).toHaveAttribute('aria-expanded', 'false') + fireEvent.click(header) + fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ })) + expect(screen.getByText('Edited')).toBeInTheDocument() + expect(screen.getByText('after')).toBeInTheDocument() + expect(screen.getByText('before')).toBeInTheDocument() + expect(scrollTo).toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: /Edited 1 file/ })) + expect(screen.queryByRole('button', { name: /^Edited .*a\.ts(?:\s|$)/ })).toBeNull() + fireEvent.click(header) + expect(header).toHaveAttribute('aria-expanded', 'false') + fireEvent.click(header) + fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ })) + expect(scrollTo.mock.calls.length).toBeGreaterThanOrEqual(2) + }) + + it('updates journal revisions and replaces flat approval text with a passive receipt', () => { + const approval = item( + 'approval', + { + kind: 'approval', + title: 'Run tests?', + detail: 'pnpm test', + options: [{ id: 'allow', label: 'Allow once' }], + resolution: { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + }, + 4 + ) + const initial = [user, prose, diff(), approval] + const { rerender } = render(view(initial)) + expect(screen.queryByText('Run tests?')).toBeNull() + if (approval.body.kind !== 'approval') { + throw new Error('fixture') + } + const resolved = { + ...approval, + revision: 2, + body: { + ...approval.body, + resolution: { + state: 'resolved' as const, + selectedOptionId: 'allow', + resolvedBy: 'desktop', + resolvedAt: 5000 + } + } + } + rerender(view([user, prose, diff('@@ -0,0 +1,2 @@\n+first\n+second'), resolved])) + expect(screen.getByRole('button', { name: /1 changed file \+2/ })).toBeInTheDocument() + expect(screen.getByText('Run tests?')).toBeInTheDocument() + expect(screen.getByText('Allow once')).toBeInTheDocument() + expect(screen.getByText('Answered on desktop')).toBeInTheDocument() + expect(screen.getByText('Resolved').closest('[data-native-chat-receipt]')).not.toBeNull() + expect(screen.queryByText('resolved')).toBeNull() + }) + + it('keeps rollups turn-local and leaves legacy message lists unchanged', () => { + const secondUser = item( + 'user-two', + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Again' }] }, + 5 + ) + const secondDiff = { ...diff(), itemId: 'second-diff', sequence: 6, observedAt: 6000 } + const items = [user, prose, diff(), secondUser, secondDiff] + const { rerender } = render(view(items)) + expect(screen.getAllByRole('button', { name: /1 changed file/ })).toHaveLength(2) + rerender(view(items, false)) + expect(screen.queryByRole('button', { name: /changed file/ })).toBeNull() + }) + }) +} diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.message-rail-windowing.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.message-rail-windowing.test.tsx deleted file mode 100644 index ba5ea61c755..00000000000 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.message-rail-windowing.test.tsx +++ /dev/null @@ -1,203 +0,0 @@ -// @vitest-environment happy-dom - -import '@testing-library/jest-dom/vitest' - -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - AgentJournalItemBody, - AgentJournalRenderItem -} from '../../../../shared/agent-session-journal-types' -import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection' -import type { NativeChatMessage } from '../../../../shared/native-chat-types' -import { NativeChatMessageList } from './NativeChatMessageList' -import { - TRANSCRIPT_LENGTH, - list, - marker, - scrollTranscript, - session, - stubLayout, - windowState -} from './NativeChatMessageList.windowing-test-support' - -afterEach(cleanup) - -describe('revealing a diff from a turn rollup', () => { - let restoreLayout = (): void => {} - beforeEach(() => { - restoreLayout = stubLayout({ scrollGeometry: true, offsetChain: true }) - vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function ( - this: HTMLElement - ) { - const row = this.closest('[data-index]') - const scroller = this.closest('[data-native-chat-scroll]') - return new DOMRect( - 0, - row ? row.offsetTop - (scroller?.scrollTop ?? 0) : 0, - 100, - this.offsetHeight - ) - }) - }) - afterEach(() => { - restoreLayout() - vi.restoreAllMocks() - }) - - function journalItem(itemId: string, body: AgentJournalItemBody, sequence: number) { - return { itemId, body, sequence, observedAt: sequence * 1000, revision: 1 } - } - - const patch = '@@ -1 +1 @@\n-before\n+after' - const items: AgentJournalRenderItem[] = [ - journalItem( - 'user', - { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Edit it' }] }, - 1 - ), - journalItem( - 'diff', - { - kind: 'diff', - path: 'src/a.ts', - patch: { head: patch, truncated: false, digest: 'fixture', byteLength: patch.length } - }, - 2 - ), - ...Array.from({ length: TRANSCRIPT_LENGTH }, (_, index) => - journalItem( - `tail-${index}`, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `marker-${index}` }] }, - index + 3 - ) - ) - ] - - it('lets a rail jump supersede a previously revealed diff', () => { - const withPrompts = [ - ...items.slice(0, 2), - journalItem( - 'user-2', - { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Second prompt' }] }, - 3 - ), - journalItem( - 'user-3', - { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Third prompt' }] }, - 4 - ), - ...items.slice(2) - ].map((item, index) => ({ ...item, sequence: index + 1 })) - const scrollTo = vi.fn() - vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo) - const transcript = (): React.JSX.Element => ( - - ) - const { container, rerender } = render(transcript()) - const selectedTop = - screen.getByText('Second prompt').closest('[data-index]')?.offsetTop ?? 0 - expect(selectedTop).toBeGreaterThan(0) - fireEvent.click(screen.getByRole('button', { name: /1 changed file/ })) - fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ })) - scrollTranscript(container, 6000) - expect(screen.getByText('Edited')).toBeInTheDocument() - scrollTo.mockClear() - fireEvent.click(screen.getByRole('button', { name: 'Second prompt' })) - expect(scrollTo.mock.calls).toEqual([ - [{ top: 6000, behavior: 'auto' }], - [{ top: selectedTop, behavior: 'smooth' }] - ]) - expect(screen.queryByRole('button', { name: /^Edited .*a\.ts(?:\s|$)/ })).toBeNull() - scrollTo.mockClear() - rerender(transcript()) - rerender(transcript()) - expect(scrollTo).not.toHaveBeenCalled() - - scrollTranscript(container, 0) - scrollTo.mockClear() - fireEvent.click(screen.getByRole('button', { name: /1 changed file/ })) - fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ })) - const diffTop = screen.getByText('Edited').closest('[data-index]')?.offsetTop ?? 0 - expect(Number.isFinite(diffTop)).toBe(true) - expect(scrollTo.mock.calls.filter(([options]) => options?.behavior === 'smooth')).toEqual([ - [{ top: diffTop, behavior: 'smooth' }] - ]) - }) -}) - -// The rail borrows the reveal's pin to reach a row the window has left behind. -// Borrowing the pin means it also has to give it back: the request is what -// outranks a later reveal, and slots is rebuilt every render, so an effect that -// merely watched it would re-scroll forever. -describe('jumping to a message from the rail', () => { - let restoreLayout = (): void => {} - beforeEach(() => { - restoreLayout = stubLayout() - }) - afterEach(() => { - restoreLayout() - vi.useRealTimers() - vi.restoreAllMocks() - }) - - function userMarker(index: number): NativeChatMessage { - return { - id: `message-${index}`, - role: 'user', - blocks: [{ type: 'text', text: `prompt-${index}` }], - timestamp: index + 1, - source: 'transcript' - } - } - - const conversation = Array.from({ length: TRANSCRIPT_LENGTH }, (_, index) => - index % 10 === 0 ? userMarker(index) : marker(index) - ) - - function jumpToFirstPrompt(): void { - fireEvent.click(screen.getByRole('button', { name: 'prompt-0' })) - act(() => { - vi.advanceTimersByTime(300) - }) - } - - it('scrolls once for a selection, not again on every later render', () => { - vi.useFakeTimers() - const scrollTo = vi.fn() - vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo) - const { container, rerender } = render(list(conversation)) - scrollTranscript(container, 6000) - - jumpToFirstPrompt() - expect(scrollTo).toHaveBeenCalled() - - // A streaming turn re-renders constantly with the same messages. The jump is - // spent; nothing here may drag the reader back to the row they left. - scrollTo.mockClear() - rerender(list(conversation)) - rerender(list(conversation)) - expect(scrollTo).not.toHaveBeenCalled() - }) - - it('releases the pin once the jump is spent', () => { - vi.useFakeTimers() - const scrollTo = vi.fn() - vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo) - const { container } = render(list(conversation)) - scrollTranscript(container, 6000) - - jumpToFirstPrompt() - expect(scrollTo).toHaveBeenCalled() - - // The request is spent as soon as the scroll is issued, so the row it pinned - // is not held in the window afterwards. A pin still standing here would also - // still outrank a diff reveal, which shares the same slot. - expect(windowState(container).indexes).not.toContain(0) - }) -}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.subagent-sections.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.subagent-sections.test.tsx index 17f4231417b..4c44f539216 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.subagent-sections.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.subagent-sections.test.tsx @@ -2,363 +2,583 @@ import '@testing-library/jest-dom/vitest' -import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, within, act } from '@testing-library/react' + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + import type { AgentJournalItemBody, AgentJournalProducerLinkage, AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' -import type { NativeChatSubagentState } from '../../../../shared/native-chat-types' + +import type { + NativeChatSubagentState, + NativeChatMessage +} from '../../../../shared/native-chat-types' + import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection' + import { foldStructuredAgentSubagentRoster, NO_STRUCTURED_AGENT_SUBAGENT_ROSTER } from '../../../../shared/structured-agent-session-subagent-roster' + import { NativeChatMessageList } from './NativeChatMessageList' + import { session, stubLayout } from './native-chat-windowing-test-harness' -afterEach(cleanup) +import { + TRANSCRIPT_LENGTH, + list, + marker, + scrollTranscript, + session as materialRailSession, + stubLayout as materialRailStubLayout, + windowState +} from './NativeChatMessageList.windowing-test-support' -function journalItem( - itemId: string, - body: AgentJournalItemBody, - sequence: number, - linkage: AgentJournalProducerLinkage = {} -): AgentJournalRenderItem { - return { itemId, body, sequence, observedAt: sequence * 1000, revision: 1, ...linkage } -} +{ + function journalItem( + itemId: string, + body: AgentJournalItemBody, + sequence: number, + linkage: AgentJournalProducerLinkage = {} + ): AgentJournalRenderItem { + return { itemId, body, sequence, observedAt: sequence * 1000, revision: 1, ...linkage } + } -const child: AgentJournalProducerLinkage = { agentId: 'task-1', producerKind: 'agent' } -const patch = '@@ -1 +1 @@\n-before\n+after' -const itemsWith = (state: NativeChatSubagentState, answered: boolean): AgentJournalRenderItem[] => [ - journalItem( - 'ask', - { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Review it' }] }, - 1 - ), - journalItem( - 'spawn', - { - kind: 'message', - role: 'system', - blocks: [ - { - type: 'subagent-group', - groupId: 'group-1', - agents: [{ id: 'task-1', label: 'explore the lane', state }] - } - ] - }, - 2 - ), - journalItem( - 'child-said', - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'The PR is CLEAN.' }] }, - 3, - child - ), - journalItem( - 'child-edit', - { - kind: 'diff', - path: 'src/a.ts', - patch: { head: patch, truncated: false, digest: 'fixture', byteLength: patch.length } - }, - 4, - child - ), - ...(answered - ? [ - journalItem( - 'answer', + const child: AgentJournalProducerLinkage = { agentId: 'task-1', producerKind: 'agent' } + const patch = '@@ -1 +1 @@\n-before\n+after' + const itemsWith = ( + state: NativeChatSubagentState, + answered: boolean + ): AgentJournalRenderItem[] => [ + journalItem( + 'ask', + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Review it' }] }, + 1 + ), + journalItem( + 'spawn', + { + kind: 'message', + role: 'system', + blocks: [ { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: 'Delegated; nothing to fix.' }] - }, - 5 - ) - ] - : []) -] + type: 'subagent-group', + groupId: 'group-1', + agents: [{ id: 'task-1', label: 'explore the lane', state }] + } + ] + }, + 2 + ), + journalItem( + 'child-said', + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'The PR is CLEAN.' }] }, + 3, + child + ), + journalItem( + 'child-edit', + { + kind: 'diff', + path: 'src/a.ts', + patch: { head: patch, truncated: false, digest: 'fixture', byteLength: patch.length } + }, + 4, + child + ), + ...(answered + ? [ + journalItem( + 'answer', + { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'Delegated; nothing to fix.' }] + }, + 5 + ) + ] + : []) + ] -/** `waiting`: the session runs and has produced nothing since the roster. */ -function listOf(state: NativeChatSubagentState, waiting = false): React.JSX.Element { - const items = itemsWith(state, !waiting) - return ( - - ) -} - -const renderList = () => render(listOf('completed')) - -describe("a subagent's rows in the transcript", () => { - let restoreLayout = (): void => {} - beforeEach(() => { - restoreLayout = stubLayout() - }) - afterEach(() => { - restoreLayout() - vi.restoreAllMocks() - }) - - it("shows the conversation and none of the subagent's words until its entry opens them", () => { - renderList() - expect(screen.getByText('Delegated; nothing to fix.')).toBeInTheDocument() - expect(screen.queryByText('The PR is CLEAN.')).toBeNull() - - fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ })) - fireEvent.click(screen.getByRole('button', { name: /explore the lane/, expanded: false })) - - // Named once: its entry heads its rows, which come before the parent's answer. - expect(screen.getAllByText('explore the lane')).toHaveLength(1) - const entry = screen.getByRole('button', { name: /explore the lane/, expanded: true }) - const said = screen.getByText('The PR is CLEAN.') - expect(entry.compareDocumentPosition(said)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) - expect(said.compareDocumentPosition(screen.getByText('Delegated; nothing to fix.'))).toBe( - Node.DOCUMENT_POSITION_FOLLOWING - ) - - fireEvent.click(entry) - expect(screen.queryByText('The PR is CLEAN.')).toBeNull() - expect(screen.getByRole('button', { name: /explore the lane/, expanded: false })).toBeVisible() - }) - - // happy-dom has no layout, so the geometry is pinned by the margin that decides it: - // outside a section a row's controls hang into the gap below it (`-mb-5`), and - // inside one that overhang would leave them below the section's border, touching - // the parent's next row. - it("keeps a section row's controls inside the row that carries the section's border", () => { - renderList() - fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ })) - fireEvent.click(screen.getByRole('button', { name: /explore the lane/, expanded: false })) - const controlsOf = (text: string): HTMLElement => { - const row = screen.getByText(text).closest('.group') - expect(row).not.toBeNull() - return within(row!).getByRole('button', { name: 'Scroll this message to top' }).parentElement! - } - - const sectionControls = controlsOf('The PR is CLEAN.') - expect(sectionControls.closest('.border-l-2')).not.toBeNull() - expect(sectionControls).not.toHaveClass('-mb-5') - expect(controlsOf('Delegated; nothing to fix.')).toHaveClass('-mb-5') - }) - - it("draws an open agent's rows between its entry and the next one", () => { - const agents = [ - { id: 'task-a', label: 'lane a', state: 'completed' as const }, - { id: 'task-b', label: 'lane b', state: 'completed' as const }, - { id: 'task-c', label: 'lane c', state: 'completed' as const } - ] - const items = [ - itemsWith('completed', false)[0]!, - journalItem( - 'spawn', - { - kind: 'message', - role: 'system', - blocks: [{ type: 'subagent-group', groupId: 'group-1', agents }] - }, - 2 - ), - ...agents.map(({ id, label }, index) => - journalItem( - `${id}-said`, - { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: `Read ${label}.` }] - }, - 3 + index, - { agentId: id, producerKind: 'agent' } - ) - ) - ] - render( + /** `waiting`: the session runs and has produced nothing since the roster. */ + function listOf(state: NativeChatSubagentState, waiting = false): React.JSX.Element { + const items = itemsWith(state, !waiting) + return ( ) - fireEvent.click(screen.getByRole('button', { name: /Ran 3 subagents/ })) - fireEvent.click(screen.getByRole('button', { name: /lane b/ })) + } - const order = [ - screen.getByRole('button', { name: /lane a/, expanded: false }), - screen.getByRole('button', { name: /lane b/, expanded: true }), - screen.getByText('Read lane b.'), - screen.getByRole('button', { name: /lane c/, expanded: false }) - ] - for (const [index, node] of order.slice(1).entries()) { - expect(order[index]!.compareDocumentPosition(node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) - } - expect(screen.queryByText('Read lane a.')).toBeNull() - expect(screen.getAllByText(/^lane [abc]$/)).toHaveLength(3) - }) + const renderList = () => render(listOf('completed')) - it("still counts the subagent's edit in the turn, and reveals it inside its section", () => { - vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(() => {}) - renderList() - expect(screen.queryByRole('button', { name: /^Edited .*a\.ts(?:\s|$)/ })).toBeNull() + describe("a subagent's rows in the transcript", () => { + afterEach(cleanup) + let restoreLayout = (): void => {} + beforeEach(() => { + restoreLayout = stubLayout() + }) + afterEach(() => { + restoreLayout() + vi.restoreAllMocks() + }) - fireEvent.click(screen.getByRole('button', { name: /1 changed file/ })) - fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ })) + it("shows the conversation and none of the subagent's words until its entry opens them", () => { + renderList() + expect(screen.getByText('Delegated; nothing to fix.')).toBeInTheDocument() + expect(screen.queryByText('The PR is CLEAN.')).toBeNull() - expect(screen.getByText('Edited')).toBeInTheDocument() - expect(screen.getByText('The PR is CLEAN.')).toBeInTheDocument() - expect(screen.getByRole('button', { name: /explore the lane/, expanded: true })).toBeVisible() - }) + fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ })) + fireEvent.click(screen.getByRole('button', { name: /explore the lane/, expanded: false })) - // Rows are grouped by the journal's turn records, so a subagent's edit counts in the - // turn that was running when it was made: not a send queued mid-turn, whose own turn - // had not opened, and in a turn keyed to its own record rather than the last prompt. - it("counts a subagent's edit in the turn the journal says it was made in", () => { - const say = (itemId: string, role: 'user' | 'assistant', text: string, sequence: number) => - journalItem(itemId, { kind: 'message', role, blocks: [{ type: 'text', text }] }, sequence) - const turn = (turnId: string, userItemId: string, sequence: number) => - journalItem( - `turn-${turnId}`, - { kind: 'turn', turnId, state: 'completed', userItemId, startedAt: sequence * 1000 }, - sequence + // Named once: its entry heads its rows, which come before the parent's answer. + expect(screen.getAllByText('explore the lane')).toHaveLength(1) + const entry = screen.getByRole('button', { name: /explore the lane/, expanded: true }) + const said = screen.getByText('The PR is CLEAN.') + expect(entry.compareDocumentPosition(said)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect(said.compareDocumentPosition(screen.getByText('Delegated; nothing to fix.'))).toBe( + Node.DOCUMENT_POSITION_FOLLOWING ) - const childEdit = (itemId: string, path: string, sequence: number) => - journalItem( - itemId, - { - kind: 'diff', - path, - patch: { head: patch, truncated: false, digest: path, byteLength: patch.length } - }, - sequence, - child - ) - const [ask, spawn] = itemsWith('completed', false) - const items = [ - ask!, - turn('1', 'ask', 2), - { ...spawn!, sequence: 3 }, - say('ask-2', 'user', 'And the tests?', 4), - childEdit('child-edit', 'src/a.ts', 5), - say('answer-1', 'assistant', 'Edited a.', 6), - turn('2', 'ask-2', 7), - say('answer-2', 'assistant', 'Tests pass.', 8), - // Its opener is outside the loaded window, so the turn keys to its own record. - turn('3', 'older-send', 9), - childEdit('child-woke', 'src/b.ts', 10), - say('answer-3', 'assistant', 'Picked up the result.', 11) - ] - render( - - ) - const rollups = screen.getAllByRole('button', { name: /changed file/ }) - expect(rollups.map((rollup) => rollup.textContent)).toEqual([ - expect.stringMatching(/1 changed file/), - expect.stringMatching(/1 changed file/) - ]) - const inOrder = [ - screen.getByText('Edited a.'), - rollups[0]!, - screen.getByText('Tests pass.'), - screen.getByText('Picked up the result.'), - rollups[1]! - ] - for (const [index, node] of inOrder.slice(1).entries()) { - expect(inOrder[index]!.compareDocumentPosition(node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) - } - }) - it('reveals an edit under a roster list the reader closed', () => { - vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(() => {}) - renderList() - fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ })) - fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ })) + fireEvent.click(entry) + expect(screen.queryByText('The PR is CLEAN.')).toBeNull() + expect( + screen.getByRole('button', { name: /explore the lane/, expanded: false }) + ).toBeVisible() + }) - fireEvent.click(screen.getByRole('button', { name: /1 changed file/ })) - fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ })) + // happy-dom has no layout, so the geometry is pinned by the margin that decides it: + // outside a section a row's controls hang into the gap below it (`-mb-5`), and + // inside one that overhang would leave them below the section's border, touching + // the parent's next row. + it("keeps a section row's controls inside the row that carries the section's border", () => { + renderList() + fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ })) + fireEvent.click(screen.getByRole('button', { name: /explore the lane/, expanded: false })) + const controlsOf = (text: string): HTMLElement => { + const row = screen.getByText(text).closest('.group') + expect(row).not.toBeNull() + return within(row!).getByRole('button', { name: 'Scroll this message to top' }) + .parentElement! + } - expect(screen.getByText('Edited')).toBeInTheDocument() - expect(screen.getByRole('button', { name: /Ran 1 subagent/, expanded: true })).toBeVisible() - }) + const sectionControls = controlsOf('The PR is CLEAN.') + expect(sectionControls.closest('.border-l-2')).not.toBeNull() + expect(sectionControls).not.toHaveClass('-mb-5') + expect(controlsOf('Delegated; nothing to fix.')).toHaveClass('-mb-5') + }) - it('keeps a roster list open or closed past its row unmounting, and hides its sections while closed', () => { - const items = itemsWith('completed', true) - const view = (withRoster: boolean) => { - const shown = withRoster ? items : items.filter((next) => next.itemId !== 'spawn') - return ( + it("draws an open agent's rows between its entry and the next one", () => { + const agents = [ + { id: 'task-a', label: 'lane a', state: 'completed' as const }, + { id: 'task-b', label: 'lane b', state: 'completed' as const }, + { id: 'task-c', label: 'lane c', state: 'completed' as const } + ] + const items = [ + itemsWith('completed', false)[0]!, + journalItem( + 'spawn', + { + kind: 'message', + role: 'system', + blocks: [{ type: 'subagent-group', groupId: 'group-1', agents }] + }, + 2 + ), + ...agents.map(({ id, label }, index) => + journalItem( + `${id}-said`, + { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: `Read ${label}.` }] + }, + 3 + index, + { agentId: id, producerKind: 'agent' } + ) + ) + ] + render( ) + fireEvent.click(screen.getByRole('button', { name: /Ran 3 subagents/ })) + fireEvent.click(screen.getByRole('button', { name: /lane b/ })) + + const order = [ + screen.getByRole('button', { name: /lane a/, expanded: false }), + screen.getByRole('button', { name: /lane b/, expanded: true }), + screen.getByText('Read lane b.'), + screen.getByRole('button', { name: /lane c/, expanded: false }) + ] + for (const [index, node] of order.slice(1).entries()) { + expect(order[index]!.compareDocumentPosition(node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + } + expect(screen.queryByText('Read lane a.')).toBeNull() + expect(screen.getAllByText(/^lane [abc]$/)).toHaveLength(3) + }) + + it("still counts the subagent's edit in the turn, and reveals it inside its section", () => { + vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(() => {}) + renderList() + expect(screen.queryByRole('button', { name: /^Edited .*a\.ts(?:\s|$)/ })).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: /1 changed file/ })) + fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ })) + + expect(screen.getByText('Edited')).toBeInTheDocument() + expect(screen.getByText('The PR is CLEAN.')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /explore the lane/, expanded: true })).toBeVisible() + }) + + // Rows are grouped by the journal's turn records, so a subagent's edit counts in the + // turn that was running when it was made: not a send queued mid-turn, whose own turn + // had not opened, and in a turn keyed to its own record rather than the last prompt. + it("counts a subagent's edit in the turn the journal says it was made in", () => { + const say = (itemId: string, role: 'user' | 'assistant', text: string, sequence: number) => + journalItem(itemId, { kind: 'message', role, blocks: [{ type: 'text', text }] }, sequence) + const turn = (turnId: string, userItemId: string, sequence: number) => + journalItem( + `turn-${turnId}`, + { kind: 'turn', turnId, state: 'completed', userItemId, startedAt: sequence * 1000 }, + sequence + ) + const childEdit = (itemId: string, path: string, sequence: number) => + journalItem( + itemId, + { + kind: 'diff', + path, + patch: { head: patch, truncated: false, digest: path, byteLength: patch.length } + }, + sequence, + child + ) + const [ask, spawn] = itemsWith('completed', false) + const items = [ + ask!, + turn('1', 'ask', 2), + { ...spawn!, sequence: 3 }, + say('ask-2', 'user', 'And the tests?', 4), + childEdit('child-edit', 'src/a.ts', 5), + say('answer-1', 'assistant', 'Edited a.', 6), + turn('2', 'ask-2', 7), + say('answer-2', 'assistant', 'Tests pass.', 8), + // Its opener is outside the loaded window, so the turn keys to its own record. + turn('3', 'older-send', 9), + childEdit('child-woke', 'src/b.ts', 10), + say('answer-3', 'assistant', 'Picked up the result.', 11) + ] + render( + + ) + const rollups = screen.getAllByRole('button', { name: /changed file/ }) + expect(rollups.map((rollup) => rollup.textContent)).toEqual([ + expect.stringMatching(/1 changed file/), + expect.stringMatching(/1 changed file/) + ]) + const inOrder = [ + screen.getByText('Edited a.'), + rollups[0]!, + screen.getByText('Tests pass.'), + screen.getByText('Picked up the result.'), + rollups[1]! + ] + for (const [index, node] of inOrder.slice(1).entries()) { + expect(inOrder[index]!.compareDocumentPosition(node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + } + }) + + it('reveals an edit under a roster list the reader closed', () => { + vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(() => {}) + renderList() + fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ })) + fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ })) + + fireEvent.click(screen.getByRole('button', { name: /1 changed file/ })) + fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ })) + + expect(screen.getByText('Edited')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Ran 1 subagent/, expanded: true })).toBeVisible() + }) + + it('keeps a roster list open or closed past its row unmounting, and hides its sections while closed', () => { + const items = itemsWith('completed', true) + const view = (withRoster: boolean) => { + const shown = withRoster ? items : items.filter((next) => next.itemId !== 'spawn') + return ( + + ) + } + const { rerender } = render(view(true)) + fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ })) + fireEvent.click(screen.getByRole('button', { name: /explore the lane/, expanded: false })) + expect(screen.getByText('The PR is CLEAN.')).toBeInTheDocument() + + rerender(view(false)) + rerender(view(true)) + expect(screen.getByRole('button', { name: /Ran 1 subagent/, expanded: true })).toBeVisible() + + fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ })) + expect(screen.queryByText('The PR is CLEAN.')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ })) + expect(screen.getByText('The PR is CLEAN.')).toBeInTheDocument() + }) + + it('opens a working subagent while the session waits on it, and closes it once the parent moves on', () => { + const { rerender } = render(listOf('working', true)) + expect(screen.getByText('The PR is CLEAN.')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /explore the lane/, expanded: true })).toBeVisible() + expect(screen.getAllByText('explore the lane')).toHaveLength(1) + rerender(listOf('working')) + expect(screen.queryByText('The PR is CLEAN.')).toBeNull() + // The closed roster still says the agent works. + expect(screen.getByRole('button', { name: /Kicked off 1 subagent/ })).toBeInTheDocument() + }) + + it("keeps the reader's choice over the frontier, in either direction", () => { + const { rerender } = render(listOf('working', true)) + fireEvent.click(screen.getByRole('button', { name: /explore the lane/, expanded: true })) + rerender(listOf('working', true)) + expect(screen.queryByText('The PR is CLEAN.')).toBeNull() + + // Closing the section from its entry left the list open. + fireEvent.click(screen.getByRole('button', { name: /explore the lane/, expanded: false })) + rerender(listOf('working')) + expect(screen.getByText('The PR is CLEAN.')).toBeInTheDocument() + }) + + it('names a section from the client roster when its roster row is not loaded', () => { + const items = itemsWith('completed', true) + const loaded = items.filter((next) => next.itemId !== 'spawn') + render( + + ) + expect( + screen.getByRole('button', { name: /explore the lane/, expanded: false }) + ).toBeVisible() + }) + }) +} + +{ + const session = materialRailSession + const stubLayout = materialRailStubLayout + + describe('revealing a diff from a turn rollup', () => { + afterEach(cleanup) + let restoreLayout = (): void => {} + beforeEach(() => { + restoreLayout = stubLayout({ scrollGeometry: true, offsetChain: true }) + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function ( + this: HTMLElement + ) { + const row = this.closest('[data-index]') + const scroller = this.closest('[data-native-chat-scroll]') + return new DOMRect( + 0, + row ? row.offsetTop - (scroller?.scrollTop ?? 0) : 0, + 100, + this.offsetHeight + ) + }) + }) + afterEach(() => { + restoreLayout() + vi.restoreAllMocks() + }) + + function journalItem(itemId: string, body: AgentJournalItemBody, sequence: number) { + return { itemId, body, sequence, observedAt: sequence * 1000, revision: 1 } } - const { rerender } = render(view(true)) - fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ })) - fireEvent.click(screen.getByRole('button', { name: /explore the lane/, expanded: false })) - expect(screen.getByText('The PR is CLEAN.')).toBeInTheDocument() - rerender(view(false)) - rerender(view(true)) - expect(screen.getByRole('button', { name: /Ran 1 subagent/, expanded: true })).toBeVisible() + const patch = '@@ -1 +1 @@\n-before\n+after' + const items: AgentJournalRenderItem[] = [ + journalItem( + 'user', + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Edit it' }] }, + 1 + ), + journalItem( + 'diff', + { + kind: 'diff', + path: 'src/a.ts', + patch: { head: patch, truncated: false, digest: 'fixture', byteLength: patch.length } + }, + 2 + ), + ...Array.from({ length: TRANSCRIPT_LENGTH }, (_, index) => + journalItem( + `tail-${index}`, + { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: `marker-${index}` }] + }, + index + 3 + ) + ) + ] - fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ })) - expect(screen.queryByText('The PR is CLEAN.')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ })) - expect(screen.getByText('The PR is CLEAN.')).toBeInTheDocument() + it('lets a rail jump supersede a previously revealed diff', () => { + const withPrompts = [ + ...items.slice(0, 2), + journalItem( + 'user-2', + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Second prompt' }] }, + 3 + ), + journalItem( + 'user-3', + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Third prompt' }] }, + 4 + ), + ...items.slice(2) + ].map((item, index) => ({ ...item, sequence: index + 1 })) + const scrollTo = vi.fn() + vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo) + const transcript = (): React.JSX.Element => ( + + ) + const { container, rerender } = render(transcript()) + const selectedTop = + screen.getByText('Second prompt').closest('[data-index]')?.offsetTop ?? 0 + expect(selectedTop).toBeGreaterThan(0) + fireEvent.click(screen.getByRole('button', { name: /1 changed file/ })) + fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ })) + scrollTranscript(container, 6000) + expect(screen.getByText('Edited')).toBeInTheDocument() + scrollTo.mockClear() + fireEvent.click(screen.getByRole('button', { name: 'Second prompt' })) + expect(scrollTo.mock.calls).toEqual([ + [{ top: 6000, behavior: 'auto' }], + [{ top: selectedTop, behavior: 'smooth' }] + ]) + expect(screen.queryByRole('button', { name: /^Edited .*a\.ts(?:\s|$)/ })).toBeNull() + scrollTo.mockClear() + rerender(transcript()) + rerender(transcript()) + expect(scrollTo).not.toHaveBeenCalled() + + scrollTranscript(container, 0) + scrollTo.mockClear() + fireEvent.click(screen.getByRole('button', { name: /1 changed file/ })) + fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ })) + const diffTop = + screen.getByText('Edited').closest('[data-index]')?.offsetTop ?? 0 + expect(Number.isFinite(diffTop)).toBe(true) + expect(scrollTo.mock.calls.filter(([options]) => options?.behavior === 'smooth')).toEqual([ + [{ top: diffTop, behavior: 'smooth' }] + ]) + }) }) - it('opens a working subagent while the session waits on it, and closes it once the parent moves on', () => { - const { rerender } = render(listOf('working', true)) - expect(screen.getByText('The PR is CLEAN.')).toBeInTheDocument() - expect(screen.getByRole('button', { name: /explore the lane/, expanded: true })).toBeVisible() - expect(screen.getAllByText('explore the lane')).toHaveLength(1) - rerender(listOf('working')) - expect(screen.queryByText('The PR is CLEAN.')).toBeNull() - // The closed roster still says the agent works. - expect(screen.getByRole('button', { name: /Kicked off 1 subagent/ })).toBeInTheDocument() - }) + // The rail borrows the reveal's pin to reach a row the window has left behind. + // Borrowing the pin means it also has to give it back: the request is what + // outranks a later reveal, and slots is rebuilt every render, so an effect that + // merely watched it would re-scroll forever. + describe('jumping to a message from the rail', () => { + afterEach(cleanup) + let restoreLayout = (): void => {} + beforeEach(() => { + restoreLayout = stubLayout() + }) + afterEach(() => { + restoreLayout() + vi.useRealTimers() + vi.restoreAllMocks() + }) - it("keeps the reader's choice over the frontier, in either direction", () => { - const { rerender } = render(listOf('working', true)) - fireEvent.click(screen.getByRole('button', { name: /explore the lane/, expanded: true })) - rerender(listOf('working', true)) - expect(screen.queryByText('The PR is CLEAN.')).toBeNull() + function userMarker(index: number): NativeChatMessage { + return { + id: `message-${index}`, + role: 'user', + blocks: [{ type: 'text', text: `prompt-${index}` }], + timestamp: index + 1, + source: 'transcript' + } + } - // Closing the section from its entry left the list open. - fireEvent.click(screen.getByRole('button', { name: /explore the lane/, expanded: false })) - rerender(listOf('working')) - expect(screen.getByText('The PR is CLEAN.')).toBeInTheDocument() - }) - - it('names a section from the client roster when its roster row is not loaded', () => { - const items = itemsWith('completed', true) - const loaded = items.filter((next) => next.itemId !== 'spawn') - render( - + const conversation = Array.from({ length: TRANSCRIPT_LENGTH }, (_, index) => + index % 10 === 0 ? userMarker(index) : marker(index) ) - expect(screen.getByRole('button', { name: /explore the lane/, expanded: false })).toBeVisible() + + function jumpToFirstPrompt(): void { + fireEvent.click(screen.getByRole('button', { name: 'prompt-0' })) + act(() => { + vi.advanceTimersByTime(300) + }) + } + + it('scrolls once for a selection, not again on every later render', () => { + vi.useFakeTimers() + const scrollTo = vi.fn() + vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo) + const { container, rerender } = render(list(conversation)) + scrollTranscript(container, 6000) + + jumpToFirstPrompt() + expect(scrollTo).toHaveBeenCalled() + + // A streaming turn re-renders constantly with the same messages. The jump is + // spent; nothing here may drag the reader back to the row they left. + scrollTo.mockClear() + rerender(list(conversation)) + rerender(list(conversation)) + expect(scrollTo).not.toHaveBeenCalled() + }) + + it('releases the pin once the jump is spent', () => { + vi.useFakeTimers() + const scrollTo = vi.fn() + vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo) + const { container } = render(list(conversation)) + scrollTranscript(container, 6000) + + jumpToFirstPrompt() + expect(scrollTo).toHaveBeenCalled() + + // The request is spent as soon as the scroll is issued, so the row it pinned + // is not held in the window afterwards. A pin still standing here would also + // still outrank a diff reveal, which shares the same slot. + expect(windowState(container).indexes).not.toContain(0) + }) }) -}) +} diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.task-list-frames.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.task-list-frames.test.tsx deleted file mode 100644 index 459c810e96a..00000000000 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.task-list-frames.test.tsx +++ /dev/null @@ -1,249 +0,0 @@ -// @vitest-environment happy-dom - -import '@testing-library/jest-dom/vitest' - -import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' -import type { AgentJournalStatusItem } from '../../../../shared/agent-session-journal-types' -import { projectStructuredItemToNativeChat } from '../../../../shared/structured-agent-session-projection' -import type { NativeChatMessage } from '../../../../shared/native-chat-types' -import { NativeChatMessageList } from './NativeChatMessageList' -import { projectNativeChatTaskListFrames } from './native-chat-task-list-frames' -import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' -import { openToolRunMembers } from './native-chat-tool-run-members-test-support' - -let restoreViewport = (): void => {} -beforeAll(() => { - restoreViewport = installNativeChatMessageListTestViewport() -}) -afterAll(() => restoreViewport()) -afterEach(cleanup) - -function frame(id: number, status: string, overrides: { kind?: string; truncated?: boolean } = {}) { - const kind = overrides.kind ?? 'notification:turn/plan/updated' - const head = JSON.stringify({ - threadId: 'thread', - turnId: 'turn', - explanation: 'Keep verification visible', - plan: [{ step: 'Verify', status }] - }) - const body: AgentJournalStatusItem = { - kind: 'status', - text: `codex · ${kind}`, - providerFrame: { - provider: 'codex', - kind, - payload: { - head, - byteLength: new TextEncoder().encode(head).byteLength, - digest: 'fixture-digest', - truncated: overrides.truncated ?? false - } - } - } - const message = projectStructuredItemToNativeChat({ - itemId: `frame-${id}`, - revision: 1, - sequence: id, - observedAt: id, - body - }) - if (!message) { - throw new Error('Expected a projected message') - } - return message -} - -function transcript(messages: NativeChatMessage[], sessionId = 'live-codex') { - return ( - - ) -} - -describe('live Codex checklist frames', () => { - it('updates one pinned checklist from journal notifications without rewinding on pagination', () => { - const first = frame(1, 'pending') - const active = frame(2, 'inProgress') - const last = frame(3, 'completed') - const { rerender, container } = render(transcript([first])) - const toggle = screen.getByRole('button', { name: 'Tasks 0 of 1 tasks completed' }) - const viewport = container.querySelector('.overflow-y-auto')! - expect(viewport.contains(toggle)).toBe(false) - fireEvent.click(toggle) - rerender(transcript([first, active])) - expect(within(toggle.parentElement!).getByText('Verify').closest('li')).toHaveClass( - 'text-foreground' - ) - expect(within(viewport as HTMLElement).getByText('Started Verify')).toBeInTheDocument() - rerender(transcript([last])) - expect( - within( - screen.getByRole('button', { name: 'Tasks 1 of 1 tasks completed' }).parentElement! - ).getByText('Verify') - ).toHaveClass('line-through') - expect(screen.getAllByText('Keep verification visible')).toHaveLength(2) - rerender(transcript([first, active, last])) - expect(screen.getAllByText('Verify')).toHaveLength(2) - expect(screen.getByRole('button', { name: 'Tasks 1 of 1 tasks completed' })).toBe(toggle) - expect(screen.getByText('Started Verify')).toBeInTheDocument() - expect(screen.queryByText('notification:turn/plan/updated')).toBeNull() - expect(projectNativeChatTaskListFrames([last])[0]).toBe( - projectNativeChatTaskListFrames([last])[0] - ) - }) - - it('uses the latest complete snapshot across Codex tool calls and notifications', () => { - const tool: NativeChatMessage = { - id: 'tool', - role: 'assistant', - timestamp: 1, - source: 'transcript', - // Journalled like the frames it sits between. - journalPosition: { sequence: 1, index: 0 }, - blocks: [ - { - type: 'tool-call', - name: 'update_plan', - input: { plan: [{ step: 'Verify', status: 'pending' }] } - } - ] - } - render(transcript([tool, frame(3, 'completed')])) - fireEvent.click(screen.getByRole('button', { name: 'Tasks 1 of 1 tasks completed' })) - expect(screen.getAllByText('Verify')).toHaveLength(2) - expect( - within( - screen.getByRole('button', { name: 'Tasks 1 of 1 tasks completed' }).parentElement! - ).getByText('Verify') - ).toHaveClass('line-through') - }) - - it('keeps malformed, truncated, other-provider, and plan-document frames unchanged', () => { - const truncated = frame(1, 'pending', { truncated: true }) - const document = frame(2, 'pending', { kind: 'item:plan' }) - const malformed = frame(3, 'pending') - const otherProvider = frame(4, 'pending') - const malformedBlock = malformed.blocks[0] - const otherBlock = otherProvider.blocks[0] - if (malformedBlock.type === 'text' && malformedBlock.providerFrame) { - malformedBlock.providerFrame.payload.head = '{"plan":null}' - } - if (otherBlock.type === 'text' && otherBlock.providerFrame) { - otherBlock.providerFrame.provider = 'claude' - } - const messages = [truncated, document, malformed, otherProvider] - const projected = projectNativeChatTaskListFrames(messages) - projected.forEach((message, index) => expect(message).toBe(messages[index])) - render(transcript([truncated])) - // Unreadable as a list and wordless, so it is neither a checklist nor a raw row. - expect(screen.queryByText('notification:turn/plan/updated')).toBeNull() - expect(screen.queryByText('Tasks')).toBeNull() - }) - - // Stored for readers like the task list, drawn only when it has words of its own. - it('hides a wordless unrecognised frame while a plan update still becomes the checklist', () => { - const unknown = frame(1, 'pending', { kind: 'notification:future/event' }) - const failure = frame(2, 'pending', { kind: 'notification:future/failure' }) - const failureBlock = failure.blocks[0] - if (failureBlock.type === 'text') { - failureBlock.tone = 'error' - } - render(transcript([unknown, failure, frame(3, 'inProgress')])) - expect(screen.queryByText('notification:future/event')).toBeNull() - expect(screen.getByText('codex · notification:future/failure')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Tasks 0 of 1 tasks completed' })).toBeInTheDocument() - }) - - it('does not consume a neighboring tool failure as a notification result', () => { - const command: NativeChatMessage = { - id: 'command', - role: 'assistant', - timestamp: 2, - source: 'transcript', - blocks: [ - { type: 'tool-call', name: 'shell', input: { command: 'verify' }, state: 'failed' }, - { type: 'tool-result', output: 'Verification failed', isError: true } - ] - } - render(transcript([frame(1, 'pending'), command])) - expect(screen.getByRole('button', { name: 'Tasks 0 of 1 tasks completed' })).toBeInTheDocument() - openToolRunMembers() - expect(screen.getByText('Verification failed', { selector: 'pre' })).toHaveClass( - 'text-destructive' - ) - }) -}) - -describe('NativeChatMessageList task list history', () => { - it('keeps the latest Claude state after pagination and resets disclosure between sessions', () => { - const first = { - id: 'first-list', - role: 'assistant' as const, - timestamp: 1, - source: 'transcript' as const, - blocks: [ - { - type: 'tool-call' as const, - name: 'TodoWrite', - input: { - todos: [ - { content: 'Read', status: 'pending' }, - { content: 'Test', status: 'pending' } - ] - } - } - ] - } - const last = { - ...first, - id: 'last-list', - timestamp: 3, - blocks: [ - { type: 'text' as const, text: 'Ready for verification' }, - { - type: 'tool-call' as const, - name: 'TodoWrite', - input: { - todos: [ - { content: 'Read', status: 'completed' }, - { content: 'Test', status: 'pending' } - ] - } - } - ] - } - const { rerender } = render(transcript([last])) - fireEvent.click(screen.getByRole('button', { name: 'Tasks 1 of 2 tasks completed' })) - rerender(transcript([first, last])) - expect(screen.getAllByText('Read')).toHaveLength(2) - expect(screen.getByText('Completed Read')).toBeInTheDocument() - expect( - within( - screen.getByRole('button', { name: 'Tasks 1 of 2 tasks completed' }).parentElement! - ).getByText('Read') - ).toHaveClass('line-through') - expect(screen.getByText('Ready for verification')).toBeInTheDocument() - rerender(transcript([first], 'two')) - expect(screen.getByRole('button', { name: 'Tasks 0 of 2 tasks completed' })).toHaveAttribute( - 'aria-expanded', - 'false' - ) - expect(screen.getAllByText('Read')).toHaveLength(1) - rerender(transcript([], 'three')) - expect(screen.queryByText('Tasks')).toBeNull() - }) -}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx index 9cb8d8d37f4..a31c831b10a 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx @@ -3,478 +3,753 @@ import '@testing-library/jest-dom/vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' + import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + import { subagentGroupFallbackText } from '../../../../shared/native-chat-subagent-summary' + import type { NativeChatMessage, NativeChatSubagentEntry } from '../../../../shared/native-chat-types' + import type { NativeChatLiveSession } from './use-native-chat-live-session' + import { NativeChatMessageList } from './NativeChatMessageList' + import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' -let restoreViewport = (): void => {} -beforeAll(() => { - restoreViewport = installNativeChatMessageListTestViewport() -}) -afterAll(() => restoreViewport()) -afterEach(cleanup) +import type { + AgentJournalRenderItem, + AgentJournalTurnScope +} from '../../../../shared/agent-session-journal-types' -const session: NativeChatLiveSession = { - messages: [ - { - id: 'assistant-1', - role: 'assistant', - blocks: [{ type: 'text', text: 'Selectable agent response.' }], - timestamp: 1, - source: 'transcript' - } - ], - status: 'ready', - sessionId: 'session-1', - agent: 'codex', - hasMore: false, - loadingEarlier: false, - olderHistoryGeneration: 0, - loadEarlier: vi.fn(), - readPhase: 'ready' -} - -describe('NativeChatMessageList assistant messages', () => { - it('keeps a running tool live when transcript lifecycle metadata is absent', () => { - render( - - ) - - expect(screen.getByText('Running 1 command')).toBeInTheDocument() - expect(screen.getByText('sleep 5')).toBeInTheDocument() - expect(screen.queryByText('1×')).toBeNull() - expect(document.querySelector('.text-destructive')).toBeNull() - }) - - it("holds back only the running turn's controls, and shows them once it ends", () => { - const text = ( - id: string, - role: 'user' | 'assistant', - words: string, - timestamp: number - ): NativeChatMessage => ({ - id, - role, - blocks: [{ type: 'text', text: words }], - timestamp, - source: 'transcript' - }) - const list = (isWorking: boolean) => ( - - ) - const copyButtons = () => screen.queryAllByRole('button', { name: 'Copy message' }) - - const { rerender } = render(list(true)) - // The finished turn keeps its controls; user bubbles carry their own copy button. - expect(copyButtons()).toHaveLength(3) - expect(screen.getAllByRole('button', { name: 'Scroll this message to top' })).toHaveLength(1) - - rerender(list(false)) - expect(copyButtons()).toHaveLength(4) - expect(screen.getAllByRole('button', { name: 'Scroll this message to top' })).toHaveLength(2) - }) - - // Only the turn's trailing run is live. Once the agent has said something - // after it, that run is done whatever its last call still reports; a - // reasoning aside is not "after it" — the agent is still inside the batch. - it('settles a run once prose follows it, but not for a reasoning aside', () => { - const run = { - id: 'assistant-tool-1', - role: 'assistant' as const, - blocks: [ - { - type: 'tool-call' as const, - name: 'shell', - input: { command: 'sleep 5' }, - state: 'running' as const - } - ], - timestamp: 1, - source: 'transcript' as const - } - const after = (role: 'assistant' | 'reasoning') => ({ - id: `after-${role}`, - role, - blocks: [{ type: 'text' as const, text: 'Looking at the output.' }], - timestamp: 2, - source: 'transcript' as const - }) - const { rerender } = render( - - ) - expect(screen.getByText('Running 1 command')).toBeInTheDocument() - - rerender( - - ) - expect(screen.queryByText('Running 1 command')).toBeNull() - expect(screen.getByText('sleep 5')).toBeInTheDocument() - }) - - it('keeps the current tool live when a stale completed lifecycle meets active hook state', () => { - render( - - ) - - expect(screen.getByText('Running 1 command')).toBeInTheDocument() - expect(screen.getByText('sleep 5')).toBeInTheDocument() - }) -}) - -// List-level, because every defect this feature has shipped so far lived in the -// assembly between rows — the roster is its own `role: 'system'` journal row, and -// what reaches the DOM depends on `foldToolMessages`, the turn-key mapping and the -// disclosure state the list owns. Rendering `NativeChatToolRun` in isolation -// supplies those by hand and agrees with whatever the caller was asked to assume. -describe('NativeChatMessageList spawn-group roster', () => { - const ROSTER: NativeChatSubagentEntry[] = [ - { id: 'a', label: 'read', state: 'completed' }, - { id: 'b', label: 'search', state: 'failed' } - ] - - /** The exact two-block row `codexSubagentGroupBody` writes: the structured - * block plus the plain-text twin a client without the block type reads. */ - function rosterMessage(agents: NativeChatSubagentEntry[], at: number): NativeChatMessage { - return { - id: 'roster-1', - role: 'system', - blocks: [ - { type: 'text', text: subagentGroupFallbackText(agents) }, - { type: 'subagent-group', groupId: 'thread-1:turn-1', agents } - ], - timestamp: at, - source: 'transcript' - } +{ + const session: NativeChatLiveSession = { + messages: [ + { + id: 'assistant-1', + role: 'assistant', + blocks: [{ type: 'text', text: 'Selectable agent response.' }], + timestamp: 1, + source: 'transcript' + } + ], + status: 'ready', + sessionId: 'session-1', + agent: 'codex', + hasMore: false, + loadingEarlier: false, + olderHistoryGeneration: 0, + loadEarlier: vi.fn(), + readPhase: 'ready' } - // Explicit ascending timestamps: the list re-sorts by (timestamp, id), so rows - // sharing a millisecond tie-break alphabetically and the user turn can land - // last — which would strand the roster outside its own turn. - function rosterSession( - agents: NativeChatSubagentEntry[], - startedAt: number - ): NativeChatLiveSession { + describe('NativeChatMessageList assistant messages', () => { + let restoreViewport = (): void => {} + beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() + }) + afterAll(() => restoreViewport()) + afterEach(cleanup) + it('keeps a running tool live when transcript lifecycle metadata is absent', () => { + render( + + ) + + expect(screen.getByText('Running 1 command')).toBeInTheDocument() + expect(screen.getByText('sleep 5')).toBeInTheDocument() + expect(screen.queryByText('1×')).toBeNull() + expect(document.querySelector('.text-destructive')).toBeNull() + }) + + it("holds back only the running turn's controls, and shows them once it ends", () => { + const text = ( + id: string, + role: 'user' | 'assistant', + words: string, + timestamp: number + ): NativeChatMessage => ({ + id, + role, + blocks: [{ type: 'text', text: words }], + timestamp, + source: 'transcript' + }) + const list = (isWorking: boolean) => ( + + ) + const copyButtons = () => screen.queryAllByRole('button', { name: 'Copy message' }) + + const { rerender } = render(list(true)) + // The finished turn keeps its controls; user bubbles carry their own copy button. + expect(copyButtons()).toHaveLength(3) + expect(screen.getAllByRole('button', { name: 'Scroll this message to top' })).toHaveLength(1) + + rerender(list(false)) + expect(copyButtons()).toHaveLength(4) + expect(screen.getAllByRole('button', { name: 'Scroll this message to top' })).toHaveLength(2) + }) + + // Only the turn's trailing run is live. Once the agent has said something + // after it, that run is done whatever its last call still reports; a + // reasoning aside is not "after it" — the agent is still inside the batch. + it('settles a run once prose follows it, but not for a reasoning aside', () => { + const run = { + id: 'assistant-tool-1', + role: 'assistant' as const, + blocks: [ + { + type: 'tool-call' as const, + name: 'shell', + input: { command: 'sleep 5' }, + state: 'running' as const + } + ], + timestamp: 1, + source: 'transcript' as const + } + const after = (role: 'assistant' | 'reasoning') => ({ + id: `after-${role}`, + role, + blocks: [{ type: 'text' as const, text: 'Looking at the output.' }], + timestamp: 2, + source: 'transcript' as const + }) + const { rerender } = render( + + ) + expect(screen.getByText('Running 1 command')).toBeInTheDocument() + + rerender( + + ) + expect(screen.queryByText('Running 1 command')).toBeNull() + expect(screen.getByText('sleep 5')).toBeInTheDocument() + }) + + it('keeps the current tool live when a stale completed lifecycle meets active hook state', () => { + render( + + ) + + expect(screen.getByText('Running 1 command')).toBeInTheDocument() + expect(screen.getByText('sleep 5')).toBeInTheDocument() + }) + }) + + // List-level, because every defect this feature has shipped so far lived in the + // assembly between rows — the roster is its own `role: 'system'` journal row, and + // what reaches the DOM depends on `foldToolMessages`, the turn-key mapping and the + // disclosure state the list owns. Rendering `NativeChatToolRun` in isolation + // supplies those by hand and agrees with whatever the caller was asked to assume. + describe('NativeChatMessageList spawn-group roster', () => { + let restoreViewport = (): void => {} + beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() + }) + afterAll(() => restoreViewport()) + afterEach(cleanup) + const ROSTER: NativeChatSubagentEntry[] = [ + { id: 'a', label: 'read', state: 'completed' }, + { id: 'b', label: 'search', state: 'failed' } + ] + + /** The exact two-block row `codexSubagentGroupBody` writes: the structured + * block plus the plain-text twin a client without the block type reads. */ + function rosterMessage(agents: NativeChatSubagentEntry[], at: number): NativeChatMessage { + return { + id: 'roster-1', + role: 'system', + blocks: [ + { type: 'text', text: subagentGroupFallbackText(agents) }, + { type: 'subagent-group', groupId: 'thread-1:turn-1', agents } + ], + timestamp: at, + source: 'transcript' + } + } + + // Explicit ascending timestamps: the list re-sorts by (timestamp, id), so rows + // sharing a millisecond tie-break alphabetically and the user turn can land + // last — which would strand the roster outside its own turn. + function rosterSession( + agents: NativeChatSubagentEntry[], + startedAt: number + ): NativeChatLiveSession { + return { + ...session, + status: 'ready', + messages: [ + { + id: 'user-fanout', + role: 'user', + blocks: [{ type: 'text', text: 'Fan this out' }], + timestamp: startedAt, + source: 'transcript' + }, + { + id: 'assistant-fanout', + role: 'assistant', + blocks: [ + { type: 'tool-call', name: 'shell', input: { command: 'pwd' }, state: 'completed' }, + { type: 'tool-result', output: '/repo' } + ], + timestamp: startedAt + 1, + source: 'transcript' + }, + rosterMessage(agents, startedAt + 2) + ] + } + } + + // A settled turn with its activity collapsed is the resting state of the whole + // transcript, so this is the roster's normal appearance, not an edge case. The + // completed-turn disclosure guard used to swallow it here — the compact row the + // feature exists to leave behind vanished the moment its turn ended. + it('leaves the roster row behind on a settled turn whose activity is collapsed', () => { + const startedAt = Date.now() - 3000 + render( + + ) + + expect(screen.getByRole('button', { name: 'Toggle turn details' })).toHaveAttribute( + 'aria-expanded', + 'false' + ) + expect(screen.getByRole('button', { name: /Ran 2 subagents/ })).toHaveTextContent('1 failed') + // The twin is the roster written out for clients that cannot draw the block. + // This one draws it, so printing the sentence too would say it all twice. + expect(screen.queryByText('Ran 2 subagents (1 failed)')).toBeNull() + }) + + // The block is provider-agnostic — the Claude lane feeds it too — so a lane + // that folds a roster into a message carrying real prose is a live shape. The + // filter used to drop EVERY text block once a roster was present, so that + // prose vanished on desktop while mobile, which reads the raw blocks, kept it. + it('keeps prose beside a roster block and drops only the twin', () => { + const startedAt = Date.now() - 3000 + const twin = subagentGroupFallbackText(ROSTER) + render( + + ) + + expect(screen.getByText('Handing the audit to two children.')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Ran 2 subagents/ })).toBeInTheDocument() + expect(screen.queryByText(twin)).toBeNull() + }) + + // The reordering that kept the roster visible must not have let TOOL activity + // out from behind the same disclosure: a failed child command reading as live + // on a finished turn is what put that guard there. + it('keeps tool activity behind the disclosure the roster now bypasses', () => { + const startedAt = Date.now() - 3000 + render( + + ) + + expect(screen.queryByRole('button', { name: /pwd/ })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Toggle turn details' })) + // The run header and its single row name the same command. + expect(screen.getAllByRole('button', { name: /pwd/ }).length).toBeGreaterThan(0) + // Expanding must reveal the tools beside the roster, never a second copy of it. + expect(screen.getAllByRole('button', { name: /Ran 2 subagents/ })).toHaveLength(1) + }) + + it('reads as a live spawn while the turn is still working', () => { + render( + + ) + + expect(screen.getByRole('button', { name: /Kicked off 2 subagents/ })).toHaveTextContent( + '2 working' + ) + }) + + // The QA defect, at the seam that produced it. A mid-turn correction opens a + // NEW turn, so `isCurrentTurn` goes false for the fan-out's row and the list + // passes `activeTurnIsWorking={false}` down to the roster. The row used to + // relabel every live child `unverifiable` and flip its headline to "Ran" — + // claiming both that contact was lost and that the fan-out had finished, while + // the three real children were still running and completed 57-87s later. + it('keeps live children working after a newer turn supersedes their own', () => { + const startedAt = Date.now() - 3000 + const live = rosterSession( + [ + { id: 'a', label: 'read_readme', state: 'working', startedAt }, + { id: 'b', label: 'read_package', state: 'working', startedAt } + ], + startedAt + ) + render( + + ) + + const roster = screen.getByRole('button', { name: /Kicked off 2 subagents/ }) + expect(roster).toHaveTextContent('2 working') + expect(roster).not.toHaveTextContent('unverifiable') + expect(screen.queryByRole('button', { name: /Ran 2 subagents/ })).toBeNull() + }) + }) + + // The block schema admits `agents: []`, so a childless spawn group is a shape the + // wire allows even though no producer writes one. It draws nothing, so the row + // must not be mounted on its account: "counts as renderable" and "actually draws" + // have to answer the same. A row that passes the first and fails the second is an + // invisible div that still consumes one `gap-5` slot of the transcript. + describe('NativeChatMessageList childless spawn group', () => { + let restoreViewport = (): void => {} + beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() + }) + afterAll(() => restoreViewport()) + afterEach(cleanup) + const NO_AGENTS: NativeChatSubagentEntry[] = [] + + function rosterSession(blocks: NativeChatMessage['blocks'], at: number): NativeChatLiveSession { + return { + ...session, + status: 'ready', + messages: [ + { + id: 'user-fanout', + role: 'user', + blocks: [{ type: 'text', text: 'Fan this out' }], + timestamp: at, + source: 'transcript' + }, + { id: 'roster-1', role: 'system', blocks, timestamp: at + 1, source: 'transcript' } + ] + } + } + + /** Every slot the transcript column lays out — one per row that mounted. */ + function emptySlots(container: HTMLElement): Element[] { + const column = container.querySelector('[data-native-chat-transcript-column]') + expect(column).not.toBeNull() + return Array.from(column!.children).filter((slot) => slot.textContent === '') + } + + it('mounts no row for a bare spawn group with no children', () => { + const startedAt = Date.now() - 3000 + const { container } = render( + + ) + + expect(screen.getByText('Fan this out')).toBeInTheDocument() + expect(emptySlots(container)).toEqual([]) + }) + + it('falls back to the plain-text twin when the block it stands in for cannot draw', () => { + const startedAt = Date.now() - 3000 + const { container } = render( + + ) + + // The twin is dropped only because the block draws the roster instead. This + // one cannot, so suppressing it too would leave the row with nothing at all. + expect(screen.getByText(subagentGroupFallbackText(NO_AGENTS))).toBeInTheDocument() + expect(emptySlots(container)).toEqual([]) + }) + }) +} + +{ + const NARRATION = 'I am starting with the required readiness pass.' + const MORE_NARRATION = 'The branch is current and the reference corpus is refreshed.' + const ANSWER = 'Review complete: clean after fixes.' + + function session(startedAt: number): NativeChatLiveSession { return { - ...session, - status: 'ready', messages: [ { - id: 'user-fanout', + id: 'user-1', role: 'user', - blocks: [{ type: 'text', text: 'Fan this out' }], + blocks: [{ type: 'text', text: 'Please review this PR.' }], timestamp: startedAt, source: 'transcript' }, { - id: 'assistant-fanout', + id: 'narration-1', role: 'assistant', - blocks: [ - { type: 'tool-call', name: 'shell', input: { command: 'pwd' }, state: 'completed' }, - { type: 'tool-result', output: '/repo' } - ], + blocks: [{ type: 'text', text: NARRATION }], timestamp: startedAt + 1, source: 'transcript' }, - rosterMessage(agents, startedAt + 2) - ] - } - } - - // A settled turn with its activity collapsed is the resting state of the whole - // transcript, so this is the roster's normal appearance, not an edge case. The - // completed-turn disclosure guard used to swallow it here — the compact row the - // feature exists to leave behind vanished the moment its turn ended. - it('leaves the roster row behind on a settled turn whose activity is collapsed', () => { - const startedAt = Date.now() - 3000 - render( - - ) - - expect(screen.getByRole('button', { name: 'Toggle turn details' })).toHaveAttribute( - 'aria-expanded', - 'false' - ) - expect(screen.getByRole('button', { name: /Ran 2 subagents/ })).toHaveTextContent('1 failed') - // The twin is the roster written out for clients that cannot draw the block. - // This one draws it, so printing the sentence too would say it all twice. - expect(screen.queryByText('Ran 2 subagents (1 failed)')).toBeNull() - }) - - // The block is provider-agnostic — the Claude lane feeds it too — so a lane - // that folds a roster into a message carrying real prose is a live shape. The - // filter used to drop EVERY text block once a roster was present, so that - // prose vanished on desktop while mobile, which reads the raw blocks, kept it. - it('keeps prose beside a roster block and drops only the twin', () => { - const startedAt = Date.now() - 3000 - const twin = subagentGroupFallbackText(ROSTER) - render( - - ) - - expect(screen.getByText('Handing the audit to two children.')).toBeInTheDocument() - expect(screen.getByRole('button', { name: /Ran 2 subagents/ })).toBeInTheDocument() - expect(screen.queryByText(twin)).toBeNull() - }) - - // The reordering that kept the roster visible must not have let TOOL activity - // out from behind the same disclosure: a failed child command reading as live - // on a finished turn is what put that guard there. - it('keeps tool activity behind the disclosure the roster now bypasses', () => { - const startedAt = Date.now() - 3000 - render( - - ) - - expect(screen.queryByRole('button', { name: /pwd/ })).toBeNull() - fireEvent.click(screen.getByRole('button', { name: 'Toggle turn details' })) - // The run header and its single row name the same command. - expect(screen.getAllByRole('button', { name: /pwd/ }).length).toBeGreaterThan(0) - // Expanding must reveal the tools beside the roster, never a second copy of it. - expect(screen.getAllByRole('button', { name: /Ran 2 subagents/ })).toHaveLength(1) - }) - - it('reads as a live spawn while the turn is still working', () => { - render( - - ) - - expect(screen.getByRole('button', { name: /Kicked off 2 subagents/ })).toHaveTextContent( - '2 working' - ) - }) - - // The QA defect, at the seam that produced it. A mid-turn correction opens a - // NEW turn, so `isCurrentTurn` goes false for the fan-out's row and the list - // passes `activeTurnIsWorking={false}` down to the roster. The row used to - // relabel every live child `unverifiable` and flip its headline to "Ran" — - // claiming both that contact was lost and that the fan-out had finished, while - // the three real children were still running and completed 57-87s later. - it('keeps live children working after a newer turn supersedes their own', () => { - const startedAt = Date.now() - 3000 - const live = rosterSession( - [ - { id: 'a', label: 'read_readme', state: 'working', startedAt }, - { id: 'b', label: 'read_package', state: 'working', startedAt } - ], - startedAt - ) - render( - - ) - - const roster = screen.getByRole('button', { name: /Kicked off 2 subagents/ }) - expect(roster).toHaveTextContent('2 working') - expect(roster).not.toHaveTextContent('unverifiable') - expect(screen.queryByRole('button', { name: /Ran 2 subagents/ })).toBeNull() - }) -}) - -// The block schema admits `agents: []`, so a childless spawn group is a shape the -// wire allows even though no producer writes one. It draws nothing, so the row -// must not be mounted on its account: "counts as renderable" and "actually draws" -// have to answer the same. A row that passes the first and fails the second is an -// invisible div that still consumes one `gap-5` slot of the transcript. -describe('NativeChatMessageList childless spawn group', () => { - const NO_AGENTS: NativeChatSubagentEntry[] = [] - - function rosterSession(blocks: NativeChatMessage['blocks'], at: number): NativeChatLiveSession { - return { - ...session, - status: 'ready', - messages: [ { - id: 'user-fanout', - role: 'user', - blocks: [{ type: 'text', text: 'Fan this out' }], - timestamp: at, + id: 'work-1', + role: 'assistant', + blocks: [ + { + type: 'tool-call', + name: 'shell', + input: { command: 'pnpm test' }, + state: 'completed' + }, + { type: 'tool-result', output: 'ok' } + ], + timestamp: startedAt + 2, source: 'transcript' }, - { id: 'roster-1', role: 'system', blocks, timestamp: at + 1, source: 'transcript' } - ] + { + id: 'narration-2', + role: 'assistant', + blocks: [{ type: 'text', text: MORE_NARRATION }], + timestamp: startedAt + 3, + source: 'transcript' + }, + { + id: 'answer-1', + role: 'assistant', + blocks: [{ type: 'text', text: ANSWER }], + timestamp: startedAt + 4, + source: 'transcript' + } + ], + status: 'ready', + sessionId: 'session-1', + agent: 'codex', + hasMore: false, + loadingEarlier: false, + olderHistoryGeneration: 0, + loadEarlier: vi.fn(), + readPhase: 'ready' } } - /** Every slot the transcript column lays out — one per row that mounted. */ - function emptySlots(container: HTMLElement): Element[] { - const column = container.querySelector('[data-native-chat-transcript-column]') - expect(column).not.toBeNull() - return Array.from(column!.children).filter((slot) => slot.textContent === '') + /** The journal behind `messages`: one turn `user-1` opened, holding every later row. With + * `statesScope` false, the rows carry no turn scope, as an older host writes them. */ + function journalOf( + messages: readonly NativeChatMessage[], + statesScope: boolean + ): AgentJournalRenderItem[] { + const scope = (turnScope: AgentJournalTurnScope) => (statesScope ? { turnScope } : {}) + const [opener, ...rest] = messages + const entry = (message: NativeChatMessage, sequence: number): AgentJournalRenderItem => ({ + itemId: message.id, + revision: 0, + sequence, + observedAt: sequence, + body: { + kind: 'message', + role: message.role === 'user' ? 'user' : 'assistant', + blocks: [{ type: 'text', text: message.id }] + }, + ...scope({ kind: 'turn', turnItemId: 'turn-1' }) + }) + return [ + { ...entry(opener!, 1), ...scope({ kind: 'thread' }) }, + { + itemId: 'turn-1', + revision: 0, + sequence: 2, + observedAt: 2, + body: { kind: 'turn', turnId: 'turn-1', state: 'completed', userItemId: opener!.id }, + ...scope({ kind: 'thread' }) + }, + ...rest.map((message, index) => entry(message, index + 3)) + ] } - it('mounts no row for a bare spawn group with no children', () => { - const startedAt = Date.now() - 3000 - const { container } = render( - + // A finished turn is the transcript's resting state, and it should read as the + // answer to the prompt — not as the transcript of the work that produced it. + describe('NativeChatMessageList settled turn fold', () => { + let restoreViewport = (): void => {} + beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() + }) + afterAll(() => restoreViewport()) + afterEach(cleanup) + it('shows a settled turn as its prompt, its duration and its answer', () => { + const startedAt = Date.now() - 3000 + render( + + ) + + expect(screen.getByText('Please review this PR.')).toBeInTheDocument() + expect(screen.getByText(ANSWER)).toBeInTheDocument() + expect(screen.queryByText(NARRATION)).toBeNull() + expect(screen.queryByText(MORE_NARRATION)).toBeNull() + expect(screen.getByRole('button', { name: 'Toggle turn details' })).toHaveAttribute( + 'aria-expanded', + 'false' + ) + }) + + it('returns the whole turn when the reader opens it', () => { + const startedAt = Date.now() - 3000 + render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Toggle turn details' })) + + expect(screen.getByText(NARRATION)).toBeInTheDocument() + expect(screen.getByText(MORE_NARRATION)).toBeInTheDocument() + expect(screen.getByText(ANSWER)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Toggle turn details' })).toHaveAttribute( + 'aria-expanded', + 'true' + ) + }) + + // Nothing closes the disclosure when a turn ends; the fold arrives already + // closed. While the turn runs there is no fold at all, so the reader watches + // the work happen. + it('folds nothing while the turn is still running', () => { + const startedAt = Date.now() - 3000 + render( + + ) + + expect(screen.getByText(NARRATION)).toBeInTheDocument() + expect(screen.getByText(MORE_NARRATION)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Toggle turn details' })).toBeNull() + }) + + // The #23621 shape: the host folded a mid-turn send into the running turn, so + // the rows after it are still the opener's. One bar under the opener folds + // them all; the steered bubble stays visible and never grows a bar or group. + it.each([ + ['states each row’s turn', true], + ['states no scope', false] + ])( + "folds the rows after a mid-turn send behind the opener's bar on a host that %s", + (_host, statesScope) => { + const startedAt = Date.now() - 3000 + const base = session(startedAt) + const steer: (typeof base.messages)[number] = { + id: 'mid-b', + role: 'user', + blocks: [{ type: 'text', text: 'Also check the tests.' }], + timestamp: startedAt + 2, + source: 'transcript' + } + const messages = [...base.messages.slice(0, 3), steer, ...base.messages.slice(3)] + render( + + ) + + expect(screen.getByText('Also check the tests.')).toBeInTheDocument() + expect(screen.queryByText(NARRATION)).toBeNull() + expect(screen.queryByText(MORE_NARRATION)).toBeNull() + const toggles = screen.getAllByRole('button', { name: 'Toggle turn details' }) + expect(toggles).toHaveLength(1) + + fireEvent.click(toggles[0]!) + expect(screen.getByText(NARRATION)).toBeInTheDocument() + expect(screen.getByText(MORE_NARRATION)).toBeInTheDocument() + } ) - expect(screen.getByText('Fan this out')).toBeInTheDocument() - expect(emptySlots(container)).toEqual([]) - }) + // The answer is the LAST prose the agent produced. A turn whose final output + // is a tool run still answers with the prose before it. + it('keeps the last prose row when the turn ends on tool activity', () => { + const startedAt = Date.now() - 3000 + const base = session(startedAt) + render( + + ) - it('falls back to the plain-text twin when the block it stands in for cannot draw', () => { - const startedAt = Date.now() - 3000 - const { container } = render( - - ) - - // The twin is dropped only because the block draws the roster instead. This - // one cannot, so suppressing it too would leave the row with nothing at all. - expect(screen.getByText(subagentGroupFallbackText(NO_AGENTS))).toBeInTheDocument() - expect(emptySlots(container)).toEqual([]) + expect(screen.getByText(ANSWER)).toBeInTheDocument() + expect(screen.queryByText(NARRATION)).toBeNull() + }) }) -}) +} diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-fold.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-fold.test.tsx deleted file mode 100644 index 93fd2ac6447..00000000000 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-fold.test.tsx +++ /dev/null @@ -1,259 +0,0 @@ -// @vitest-environment happy-dom - -import '@testing-library/jest-dom/vitest' - -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' -import type { - AgentJournalRenderItem, - AgentJournalTurnScope -} from '../../../../shared/agent-session-journal-types' -import type { NativeChatMessage } from '../../../../shared/native-chat-types' -import type { NativeChatLiveSession } from './use-native-chat-live-session' -import { NativeChatMessageList } from './NativeChatMessageList' -import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' - -let restoreViewport = (): void => {} -beforeAll(() => { - restoreViewport = installNativeChatMessageListTestViewport() -}) -afterAll(() => restoreViewport()) -afterEach(cleanup) - -const NARRATION = 'I am starting with the required readiness pass.' -const MORE_NARRATION = 'The branch is current and the reference corpus is refreshed.' -const ANSWER = 'Review complete: clean after fixes.' - -function session(startedAt: number): NativeChatLiveSession { - return { - messages: [ - { - id: 'user-1', - role: 'user', - blocks: [{ type: 'text', text: 'Please review this PR.' }], - timestamp: startedAt, - source: 'transcript' - }, - { - id: 'narration-1', - role: 'assistant', - blocks: [{ type: 'text', text: NARRATION }], - timestamp: startedAt + 1, - source: 'transcript' - }, - { - id: 'work-1', - role: 'assistant', - blocks: [ - { type: 'tool-call', name: 'shell', input: { command: 'pnpm test' }, state: 'completed' }, - { type: 'tool-result', output: 'ok' } - ], - timestamp: startedAt + 2, - source: 'transcript' - }, - { - id: 'narration-2', - role: 'assistant', - blocks: [{ type: 'text', text: MORE_NARRATION }], - timestamp: startedAt + 3, - source: 'transcript' - }, - { - id: 'answer-1', - role: 'assistant', - blocks: [{ type: 'text', text: ANSWER }], - timestamp: startedAt + 4, - source: 'transcript' - } - ], - status: 'ready', - sessionId: 'session-1', - agent: 'codex', - hasMore: false, - loadingEarlier: false, - olderHistoryGeneration: 0, - loadEarlier: vi.fn(), - readPhase: 'ready' - } -} - -/** The journal behind `messages`: one turn `user-1` opened, holding every later row. With - * `statesScope` false, the rows carry no turn scope, as an older host writes them. */ -function journalOf( - messages: readonly NativeChatMessage[], - statesScope: boolean -): AgentJournalRenderItem[] { - const scope = (turnScope: AgentJournalTurnScope) => (statesScope ? { turnScope } : {}) - const [opener, ...rest] = messages - const entry = (message: NativeChatMessage, sequence: number): AgentJournalRenderItem => ({ - itemId: message.id, - revision: 0, - sequence, - observedAt: sequence, - body: { - kind: 'message', - role: message.role === 'user' ? 'user' : 'assistant', - blocks: [{ type: 'text', text: message.id }] - }, - ...scope({ kind: 'turn', turnItemId: 'turn-1' }) - }) - return [ - { ...entry(opener!, 1), ...scope({ kind: 'thread' }) }, - { - itemId: 'turn-1', - revision: 0, - sequence: 2, - observedAt: 2, - body: { kind: 'turn', turnId: 'turn-1', state: 'completed', userItemId: opener!.id }, - ...scope({ kind: 'thread' }) - }, - ...rest.map((message, index) => entry(message, index + 3)) - ] -} - -// A finished turn is the transcript's resting state, and it should read as the -// answer to the prompt — not as the transcript of the work that produced it. -describe('NativeChatMessageList settled turn fold', () => { - it('shows a settled turn as its prompt, its duration and its answer', () => { - const startedAt = Date.now() - 3000 - render( - - ) - - expect(screen.getByText('Please review this PR.')).toBeInTheDocument() - expect(screen.getByText(ANSWER)).toBeInTheDocument() - expect(screen.queryByText(NARRATION)).toBeNull() - expect(screen.queryByText(MORE_NARRATION)).toBeNull() - expect(screen.getByRole('button', { name: 'Toggle turn details' })).toHaveAttribute( - 'aria-expanded', - 'false' - ) - }) - - it('returns the whole turn when the reader opens it', () => { - const startedAt = Date.now() - 3000 - render( - - ) - - fireEvent.click(screen.getByRole('button', { name: 'Toggle turn details' })) - - expect(screen.getByText(NARRATION)).toBeInTheDocument() - expect(screen.getByText(MORE_NARRATION)).toBeInTheDocument() - expect(screen.getByText(ANSWER)).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Toggle turn details' })).toHaveAttribute( - 'aria-expanded', - 'true' - ) - }) - - // Nothing closes the disclosure when a turn ends; the fold arrives already - // closed. While the turn runs there is no fold at all, so the reader watches - // the work happen. - it('folds nothing while the turn is still running', () => { - const startedAt = Date.now() - 3000 - render( - - ) - - expect(screen.getByText(NARRATION)).toBeInTheDocument() - expect(screen.getByText(MORE_NARRATION)).toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'Toggle turn details' })).toBeNull() - }) - - // The #23621 shape: the host folded a mid-turn send into the running turn, so - // the rows after it are still the opener's. One bar under the opener folds - // them all; the steered bubble stays visible and never grows a bar or group. - it.each([ - ['states each row’s turn', true], - ['states no scope', false] - ])( - "folds the rows after a mid-turn send behind the opener's bar on a host that %s", - (_host, statesScope) => { - const startedAt = Date.now() - 3000 - const base = session(startedAt) - const steer: (typeof base.messages)[number] = { - id: 'mid-b', - role: 'user', - blocks: [{ type: 'text', text: 'Also check the tests.' }], - timestamp: startedAt + 2, - source: 'transcript' - } - const messages = [...base.messages.slice(0, 3), steer, ...base.messages.slice(3)] - render( - - ) - - expect(screen.getByText('Also check the tests.')).toBeInTheDocument() - expect(screen.queryByText(NARRATION)).toBeNull() - expect(screen.queryByText(MORE_NARRATION)).toBeNull() - const toggles = screen.getAllByRole('button', { name: 'Toggle turn details' }) - expect(toggles).toHaveLength(1) - - fireEvent.click(toggles[0]!) - expect(screen.getByText(NARRATION)).toBeInTheDocument() - expect(screen.getByText(MORE_NARRATION)).toBeInTheDocument() - } - ) - - // The answer is the LAST prose the agent produced. A turn whose final output - // is a tool run still answers with the prose before it. - it('keeps the last prose row when the turn ends on tool activity', () => { - const startedAt = Date.now() - 3000 - const base = session(startedAt) - render( - - ) - - expect(screen.getByText(ANSWER)).toBeInTheDocument() - expect(screen.queryByText(NARRATION)).toBeNull() - }) -}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-history.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-history.test.tsx deleted file mode 100644 index 5192ece83ff..00000000000 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-history.test.tsx +++ /dev/null @@ -1,268 +0,0 @@ -// @vitest-environment happy-dom -import '@testing-library/jest-dom/vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' -import type { - AgentJournalItemBody, - AgentJournalRenderItem -} from '../../../../shared/agent-session-journal-types' -import { projectStructuredQuestionMessages } from './structured-agent-question-projection' -import { NativeChatMessageList } from './NativeChatMessageList' -import type { NativeChatLiveSession } from './use-native-chat-live-session' -import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' - -const scrollTo = vi.fn() -let restoreViewport = (): void => {} -beforeAll(() => { - restoreViewport = installNativeChatMessageListTestViewport() -}) -afterAll(() => restoreViewport()) -afterEach(() => { - cleanup() - vi.restoreAllMocks() -}) -function item( - itemId: string, - body: AgentJournalItemBody, - sequence: number -): AgentJournalRenderItem { - return { itemId, body, sequence, observedAt: sequence * 1000, revision: 1 } -} -const user = item( - 'user', - { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Make the change' }] }, - 1 -) -const prose = item( - 'prose', - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Updating the files.' }] }, - 2 -) -function diff(patch = '@@ -1 +1 @@\n-before\n+after'): AgentJournalRenderItem { - return item( - 'diff', - { - kind: 'diff', - path: 'src/a.ts', - patch: { head: patch, truncated: false, digest: 'fixture', byteLength: patch.length } - }, - 3 - ) -} -function session(items: AgentJournalRenderItem[]): NativeChatLiveSession { - return { - messages: projectStructuredQuestionMessages(items), - status: 'ready', - sessionId: 'session', - agent: 'codex', - hasMore: false, - loadingEarlier: false, - olderHistoryGeneration: 0, - loadEarlier: vi.fn(), - readPhase: 'ready' - } -} -function view(items: AgentJournalRenderItem[], structured = true) { - return ( - - ) -} - -describe('turn history presentation', () => { - it('renders canonical pending questions while idle and keeps resolved answers at their row', () => { - const question = item( - 'question', - { - kind: 'question', - question: 'Which branch?', - options: [{ id: 'main', label: 'main' }], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - }, - 3 - ) - const { rerender } = render(view([user, prose, question])) - expect(screen.getByText('Awaiting user input:')).toBeInTheDocument() - expect(screen.getByText('Which branch?')).toBeInTheDocument() - expect(screen.queryByText(/request_user_input/)).toBeNull() - const settled = item( - 'question', - { - ...question.body, - kind: 'question', - question: 'Which branch?', - options: [{ id: 'main', label: 'main' }], - resolution: { - state: 'resolved', - selectedOptionId: 'main', - resolvedBy: 'desktop', - resolvedAt: 4000 - } - }, - 3 - ) - rerender(view([user, prose, settled])) - expect(screen.queryByText('Awaiting user input:')).toBeNull() - expect(screen.getByText('Asked:')).toBeInTheDocument() - expect(screen.getByText('main')).toBeInTheDocument() - }) - - it('renders one resolved row when Claude journals both the call and receipt', () => { - const call = item( - 'ask-call', - { - kind: 'tool-call', - name: 'AskUserQuestion', - input: { questions: [{ question: 'Which branch?' }] }, - state: 'completed', - output: { head: 'main', byteLength: 4, truncated: false, digest: 'answer' } - }, - 3 - ) - const question = item( - 'question-receipt', - { - kind: 'question', - question: 'Which branch?', - options: [{ id: 'main', label: 'main' }], - resolution: { - state: 'resolved', - selectedOptionId: 'main', - resolvedBy: 'desktop', - resolvedAt: 4000 - } - }, - 4 - ) - - render(view([user, call, question])) - - expect(screen.getAllByText('Asked:')).toHaveLength(1) - expect(screen.queryByText(/AskUserQuestion/)).toBeNull() - }) - - it('groups pending Codex questions then narrows the awaiting count after one answer', () => { - const first = item( - 'q1', - { - kind: 'question', - question: 'Which branch?', - options: [{ id: 'main', label: 'main' }], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - }, - 3 - ) - const second = item( - 'q2', - { - kind: 'question', - question: 'Proceed?', - options: [], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - }, - 4 - ) - const { rerender } = render(view([user, first, second])) - expect(screen.getByText('2 questions')).toBeInTheDocument() - expect(screen.getAllByText('Awaiting user input:')).toHaveLength(1) - const answered = item( - 'q1', - { - kind: 'question', - question: 'Which branch?', - options: [{ id: 'main', label: 'main' }], - resolution: { - state: 'resolved', - selectedOptionId: 'main', - resolvedBy: 'phone', - resolvedAt: 5000 - } - }, - 3 - ) - rerender(view([user, answered, second])) - expect(screen.queryByText('2 questions')).toBeNull() - expect(screen.getByText('Proceed?')).toBeInTheDocument() - expect(screen.getByText('main')).toBeInTheDocument() - expect(screen.getAllByText('Awaiting user input:')).toHaveLength(1) - }) - - it('reveals and scrolls to a folded diff card from a collapsed completed turn', () => { - vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo) - render(view([user, prose, diff()])) - expect(screen.queryByRole('button', { name: /^Edited .*a\.ts(?:\s|$)/ })).toBeNull() - const header = screen.getByRole('button', { name: /1 changed file/ }) - expect(header).toHaveAttribute('aria-expanded', 'false') - fireEvent.click(header) - fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ })) - expect(screen.getByText('Edited')).toBeInTheDocument() - expect(screen.getByText('after')).toBeInTheDocument() - expect(screen.getByText('before')).toBeInTheDocument() - expect(scrollTo).toHaveBeenCalled() - fireEvent.click(screen.getByRole('button', { name: /Edited 1 file/ })) - expect(screen.queryByRole('button', { name: /^Edited .*a\.ts(?:\s|$)/ })).toBeNull() - fireEvent.click(header) - expect(header).toHaveAttribute('aria-expanded', 'false') - fireEvent.click(header) - fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ })) - expect(scrollTo.mock.calls.length).toBeGreaterThanOrEqual(2) - }) - - it('updates journal revisions and replaces flat approval text with a passive receipt', () => { - const approval = item( - 'approval', - { - kind: 'approval', - title: 'Run tests?', - detail: 'pnpm test', - options: [{ id: 'allow', label: 'Allow once' }], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - }, - 4 - ) - const initial = [user, prose, diff(), approval] - const { rerender } = render(view(initial)) - expect(screen.queryByText('Run tests?')).toBeNull() - if (approval.body.kind !== 'approval') { - throw new Error('fixture') - } - const resolved = { - ...approval, - revision: 2, - body: { - ...approval.body, - resolution: { - state: 'resolved' as const, - selectedOptionId: 'allow', - resolvedBy: 'desktop', - resolvedAt: 5000 - } - } - } - rerender(view([user, prose, diff('@@ -0,0 +1,2 @@\n+first\n+second'), resolved])) - expect(screen.getByRole('button', { name: /1 changed file \+2/ })).toBeInTheDocument() - expect(screen.getByText('Run tests?')).toBeInTheDocument() - expect(screen.getByText('Allow once')).toBeInTheDocument() - expect(screen.getByText('Answered on desktop')).toBeInTheDocument() - expect(screen.getByText('Resolved').closest('[data-native-chat-receipt]')).not.toBeNull() - expect(screen.queryByText('resolved')).toBeNull() - }) - - it('keeps rollups turn-local and leaves legacy message lists unchanged', () => { - const secondUser = item( - 'user-two', - { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Again' }] }, - 5 - ) - const secondDiff = { ...diff(), itemId: 'second-diff', sequence: 6, observedAt: 6000 } - const items = [user, prose, diff(), secondUser, secondDiff] - const { rerender } = render(view(items)) - expect(screen.getAllByRole('button', { name: /1 changed file/ })).toHaveLength(2) - rerender(view(items, false)) - expect(screen.queryByRole('button', { name: /changed file/ })).toBeNull() - }) -}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx index 55321468e22..7f7fad535d2 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx @@ -2,484 +2,851 @@ import '@testing-library/jest-dom/vitest' -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, render, screen, fireEvent } from '@testing-library/react' + import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + import { agentJournalItemKey, agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' + import type { AgentJournalItemBody, AgentJournalRenderItem, AgentJournalSubmission, AgentJournalTurnScope } from '../../../../shared/agent-session-journal-types' + import { projectStructuredAgentSessionMessages } from '../../../../shared/structured-agent-session-message-projection' + import { selectStructuredAgentSettledTurns } from '../../../../shared/structured-agent-session-turn-timing' + import { NativeChatMessageList } from './NativeChatMessageList' + import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' -let restoreViewport = (): void => {} -beforeAll(() => { - restoreViewport = installNativeChatMessageListTestViewport() -}) -afterAll(() => restoreViewport()) -afterEach(cleanup) +import type { NativeChatLiveSession } from './use-native-chat-live-session' -const THREAD: AgentJournalTurnScope = { kind: 'thread' } -const inTurn = (turnItemId: string): AgentJournalTurnScope => ({ kind: 'turn', turnItemId }) -let sequence = 0 +import type { AgentSessionLatestTurn } from '../../../../shared/agent-session-wire' -function item( - itemId: string, - body: AgentJournalItemBody, - turnScope: AgentJournalTurnScope = THREAD -): AgentJournalRenderItem { - sequence += 1 - return { itemId, revision: 0, sequence, observedAt: sequence, body, turnScope } -} -const say = ( - itemId: string, - role: 'user' | 'assistant', - text: string, - scope: AgentJournalTurnScope = THREAD -) => item(itemId, { kind: 'message', role, blocks: [{ type: 'text', text }] }, scope) -const work = (itemId: string, scope: AgentJournalTurnScope) => - item( - itemId, - { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'tool-call', name: 'shell', input: { command: 'ls' }, state: 'completed' }] - }, - scope - ) -const settledTurn = (itemId: string, userItemId: string, seconds: number, start: number) => - item(itemId, { - kind: 'turn', - turnId: itemId, - state: 'completed', - outcome: 'success', - userItemId, - startedAt: start, - completedAt: start + seconds * 1000 - }) +import type { NativeChatMessage } from '../../../../shared/native-chat-types' -/** "List three fruits", answered and settled in 4s. */ -function fruitTurn(): AgentJournalRenderItem[] { - return [ - say('u1', 'user', 'List three fruits'), - settledTurn('t1', 'u1', 4, 1_000), - say('t1-narration', 'assistant', 'Thinking about fruit.', inTurn('t1')), - work('t1-work', inTurn('t1')), - say('t1-answer', 'assistant', 'Apple, banana, cherry.', inTurn('t1')) - ] -} +{ + const THREAD: AgentJournalTurnScope = { kind: 'thread' } + const inTurn = (turnItemId: string): AgentJournalTurnScope => ({ kind: 'turn', turnItemId }) + let sequence = 0 -function journalList( - items: AgentJournalRenderItem[], - submissions: AgentJournalSubmission[] = [], - { - isWorking = false, - workingStartedAt = null, - hostTiming = true - }: { isWorking?: boolean; workingStartedAt?: number | null; hostTiming?: boolean } = {} -): React.JSX.Element { - return ( - - ) -} - -function renderJournal( - items: AgentJournalRenderItem[], - submissions: AgentJournalSubmission[] = [], - isWorking = false -): void { - render(journalList(items, submissions, { isWorking })) -} - -function follows(later: HTMLElement, earlier: HTMLElement): boolean { - return Boolean(earlier.compareDocumentPosition(later) & Node.DOCUMENT_POSITION_FOLLOWING) -} - -describe('NativeChatMessageList turns from the turn record', () => { - it('gives /compact its own turn and result, and leaves the turn before it untouched', () => { - const compactEntry = agentJournalSubmissionKey('cmd-1') - const commandTurn = agentJournalItemKey({ - provider: 'orca', - clientMessageId: 'command-turn:cmd-1' - }) - const items = [ - ...fruitTurn(), - item(compactEntry, { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text: '/compact' }], - command: { name: 'compact' } - }), - settledTurn(commandTurn, compactEntry, 2, 10_000), - item( - 'command-result', - { kind: 'status', text: 'Conversation compacted.', presentation: 'compaction' }, - inTurn(commandTurn) - ) - ] - renderJournal(items, [ + function item( + itemId: string, + body: AgentJournalItemBody, + turnScope: AgentJournalTurnScope = THREAD + ): AgentJournalRenderItem { + sequence += 1 + return { itemId, revision: 0, sequence, observedAt: sequence, body, turnScope } + } + const say = ( + itemId: string, + role: 'user' | 'assistant', + text: string, + scope: AgentJournalTurnScope = THREAD + ) => item(itemId, { kind: 'message', role, blocks: [{ type: 'text', text }] }, scope) + const work = (itemId: string, scope: AgentJournalTurnScope) => + item( + itemId, { - clientMessageId: 'cmd-1', + kind: 'message', + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'shell', input: { command: 'ls' }, state: 'completed' }] + }, + scope + ) + const settledTurn = (itemId: string, userItemId: string, seconds: number, start: number) => + item(itemId, { + kind: 'turn', + turnId: itemId, + state: 'completed', + outcome: 'success', + userItemId, + startedAt: start, + completedAt: start + seconds * 1000 + }) + + /** "List three fruits", answered and settled in 4s. */ + function fruitTurn(): AgentJournalRenderItem[] { + return [ + say('u1', 'user', 'List three fruits'), + settledTurn('t1', 'u1', 4, 1_000), + say('t1-narration', 'assistant', 'Thinking about fruit.', inTurn('t1')), + work('t1-work', inTurn('t1')), + say('t1-answer', 'assistant', 'Apple, banana, cherry.', inTurn('t1')) + ] + } + + function journalList( + items: AgentJournalRenderItem[], + submissions: AgentJournalSubmission[] = [], + { + isWorking = false, + workingStartedAt = null, + hostTiming = true + }: { isWorking?: boolean; workingStartedAt?: number | null; hostTiming?: boolean } = {} + ): React.JSX.Element { + return ( + + ) + } + + function renderJournal( + items: AgentJournalRenderItem[], + submissions: AgentJournalSubmission[] = [], + isWorking = false + ): void { + render(journalList(items, submissions, { isWorking })) + } + + function follows(later: HTMLElement, earlier: HTMLElement): boolean { + return Boolean(earlier.compareDocumentPosition(later) & Node.DOCUMENT_POSITION_FOLLOWING) + } + + describe('NativeChatMessageList turns from the turn record', () => { + let restoreViewport = (): void => {} + beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() + }) + afterAll(() => restoreViewport()) + afterEach(cleanup) + it('gives /compact its own turn and result, and leaves the turn before it untouched', () => { + const compactEntry = agentJournalSubmissionKey('cmd-1') + const commandTurn = agentJournalItemKey({ + provider: 'orca', + clientMessageId: 'command-turn:cmd-1' + }) + const items = [ + ...fruitTurn(), + item(compactEntry, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + }), + settledTurn(commandTurn, compactEntry, 2, 10_000), + item( + 'command-result', + { kind: 'status', text: 'Conversation compacted.', presentation: 'compaction' }, + inTurn(commandTurn) + ) + ] + renderJournal(items, [ + { + clientMessageId: 'cmd-1', + fence: 1, + payloadFingerprint: 'f', + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt: 9_000, + resolvedAt: 12_000 + } + ]) + + expect(screen.getByText('Apple, banana, cherry.')).toBeInTheDocument() + expect(screen.queryByText('Thinking about fruit.')).toBeNull() + const compact = screen.getByText('/compact') + const previousStatus = screen.getByText('Worked for 4s') + const ownStatus = screen.getByText('Worked for 2s') + expect(follows(compact, previousStatus)).toBe(true) + expect(follows(ownStatus, compact)).toBe(true) + expect(screen.getByRole('separator', { name: 'Context compacted' })).toBeInTheDocument() + }) + + it('draws a message waiting behind /compact after the compaction, never above it', () => { + const compactEntry = agentJournalSubmissionKey('cmd-1') + const commandTurn = agentJournalItemKey({ + provider: 'orca', + clientMessageId: 'command-turn:cmd-1' + }) + const held = agentJournalSubmissionKey('held') + const items = [ + ...fruitTurn(), + item(compactEntry, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + }), + settledTurn(commandTurn, compactEntry, 2, 10_000), + // Accepted while the command ran, so written before the command's result. + say(held, 'user', 'Say DONE'), + item( + 'command-result', + { kind: 'status', text: 'Conversation compacted.', presentation: 'compaction' }, + inTurn(commandTurn) + ) + ] + const submission = ( + clientMessageId: string, + handedOverAt?: number + ): AgentJournalSubmission => ({ + clientMessageId, fence: 1, - payloadFingerprint: 'f', - dispatchState: 'accepted', + payloadFingerprint: clientMessageId, + dispatchState: + handedOverAt === undefined && clientMessageId === 'held' ? 'pending' : 'accepted', providerItemId: null, reason: null, submittedAt: 9_000, - resolvedAt: 12_000 + resolvedAt: null, + handoverRecorded: true, + ...(handedOverAt !== undefined ? { handedOverAt } : {}) + }) + // The command settled; the loop has not yet handed the held message over. + renderJournal(items, [submission('cmd-1', 9_500), submission('held')]) + + expect( + follows( + screen.getByText('Say DONE'), + screen.getByRole('separator', { name: 'Context compacted' }) + ) + ).toBe(true) + }) + + it('keeps the row that says why a turn stopped visible in its folded turn', () => { + const items = [ + ...fruitTurn(), + item( + 'exit', + { kind: 'status', text: 'The agent exited unexpectedly.', tone: 'error' }, + inTurn('t1') + ) + ] + renderJournal(items) + + expect(screen.queryByText('Thinking about fruit.')).toBeNull() + expect(screen.getByText('The agent exited unexpectedly.')).toBeInTheDocument() + }) + + it('folds a turn the provider resumed on its own under its own status', () => { + const wake = agentJournalItemKey({ + provider: 'legacy', + agent: 'claude', + sessionId: 'session-1', + recordId: 'turn-lifecycle:wake' + }) + const items = [ + ...fruitTurn(), + // Claude keys a resumed turn's record on itself: no message opened it. + settledTurn(wake, wake, 9, 20_000), + say('wake-narration', 'assistant', 'The background build finished.', inTurn(wake)), + work('wake-work', inTurn(wake)), + say('wake-answer', 'assistant', 'All tests pass now.', inTurn(wake)) + ] + renderJournal(items) + + const previousStatus = screen.getByText('Worked for 4s') + const ownStatus = screen.getByText('Worked for 9s') + expect(follows(ownStatus, screen.getByText('Apple, banana, cherry.'))).toBe(true) + expect(follows(ownStatus, previousStatus)).toBe(true) + expect(screen.queryByText('The background build finished.')).toBeNull() + expect(screen.getByText('All tests pass now.')).toBeInTheDocument() + expect(screen.getAllByRole('button', { name: 'Toggle turn details' })).toHaveLength(2) + }) + + it("keeps the settled turn's duration while a turn the provider opened runs after it", () => { + const wake = agentJournalItemKey({ + provider: 'legacy', + agent: 'claude', + sessionId: 'session-1', + recordId: 'turn-lifecycle:wake' + }) + const items = [ + ...fruitTurn(), + item(wake, { + kind: 'turn', + turnId: wake, + state: 'running', + userItemId: wake, + startedAt: 20_000 + }), + say('wake-answer', 'assistant', 'Checking the background build.', inTurn(wake)) + ] + renderJournal(items, [], true) + + expect(screen.getByText('Worked for 4s')).toBeInTheDocument() + expect(screen.getByText('Checking the background build.')).toBeInTheDocument() + }) + + it('clocks a turn the provider opened under its own key, not the user turn before it', () => { + const wake = 'wake' + const ask = say('u1', 'user', 'List three fruits') + const answer = (state: 'running' | 'completed') => [ + ask, + item('t1', { kind: 'turn', turnId: 't1', state, userItemId: 'u1' }), + say('t1-answer', 'assistant', 'Apple, banana, cherry.', inTurn('t1')) + ] + // No host durations: this client's own clock is all the rows have. + const local = { hostTiming: false } + const { rerender } = render( + journalList(answer('running'), [], { + ...local, + isWorking: true, + workingStartedAt: Date.now() - 7_000 + }) + ) + rerender(journalList(answer('completed'), [], local)) + expect(screen.getByText('Worked for 7s')).toBeInTheDocument() + + const woken = [ + ...answer('completed'), + item(wake, { kind: 'turn', turnId: wake, state: 'running', userItemId: 'claude:wake' }), + say('wake-answer', 'assistant', 'Checking the background build.', inTurn(wake)) + ] + rerender(journalList(woken, [], { ...local, isWorking: true, workingStartedAt: Date.now() })) + expect(screen.getByText('Worked for 7s')).toBeInTheDocument() + }) + + it('draws a message waiting behind a running /compact after its live activity', () => { + const compactEntry = agentJournalSubmissionKey('cmd-1') + const commandTurn = agentJournalItemKey({ + provider: 'orca', + clientMessageId: 'command-turn:cmd-1' + }) + const held = agentJournalSubmissionKey('held') + const items = [ + ...fruitTurn(), + item(compactEntry, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + }), + item(commandTurn, { + kind: 'turn', + turnId: 'compact:cmd-1', + state: 'running', + userItemId: compactEntry, + startedAt: 10_000 + }), + say(held, 'user', 'Say DONE') + ] + const submission = ( + clientMessageId: string, + handedOverAt?: number + ): AgentJournalSubmission => ({ + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: handedOverAt === undefined ? 'pending' : 'accepted', + providerItemId: null, + reason: null, + submittedAt: 9_000, + resolvedAt: null, + handoverRecorded: true, + ...(handedOverAt !== undefined ? { handedOverAt } : {}) + }) + const { container } = render( + journalList(items, [submission('cmd-1', 9_500), submission('held')], { + isWorking: true, + workingStartedAt: 10_000 + }) + ) + + const activity = container.querySelector('[data-native-chat-turn-activity]') + expect(activity).not.toBeNull() + expect(follows(activity!, screen.getByText('/compact'))).toBe(true) + expect(follows(screen.getByText('Say DONE'), activity!)).toBe(true) + }) + + it.each(['settled', 'running'] as const)( + "a queued card Steered into a %s turn joins it, under the opener's bar, with no bar of its own", + (phase) => { + // Steer hands the draft over under a fresh submission id, linked by `queuedMessageId`; the + // host scopes its row to the turn it joined. + const steer = agentJournalSubmissionKey('handed-off') + const running = phase === 'running' + const items = [ + say('u1', 'user', 'List three fruits'), + running + ? item('t1', { + kind: 'turn', + turnId: 't1', + state: 'running', + userItemId: 'u1', + startedAt: 1_000 + }) + : settledTurn('t1', 'u1', 4, 1_000), + say('t1-narration', 'assistant', 'Thinking about fruit.', inTurn('t1')), + say(steer, 'user', 'Make it four', inTurn('t1')), + say('t1-answer', 'assistant', 'Apple, banana, cherry, date.', inTurn('t1')) + ] + const { container } = render( + journalList( + items, + [ + { + clientMessageId: 'handed-off', + fence: 1, + payloadFingerprint: 'steer', + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt: 2_000, + resolvedAt: 2_100, + queuedMessageId: 'draft-1' + } + ], + running ? { isWorking: true, workingStartedAt: 1_000 } : {} + ) + ) + + const bars = container.querySelectorAll('[data-native-chat-turn-status]') + expect(bars).toHaveLength(1) + const opener = screen.getByText('List three fruits') + const steered = screen.getByText('Make it four') + expect(follows(bars[0]!, opener)).toBe(true) + expect(follows(steered, bars[0]!)).toBe(true) + expect(follows(screen.getByText('Apple, banana, cherry, date.'), steered)).toBe(true) } - ]) - - expect(screen.getByText('Apple, banana, cherry.')).toBeInTheDocument() - expect(screen.queryByText('Thinking about fruit.')).toBeNull() - const compact = screen.getByText('/compact') - const previousStatus = screen.getByText('Worked for 4s') - const ownStatus = screen.getByText('Worked for 2s') - expect(follows(compact, previousStatus)).toBe(true) - expect(follows(ownStatus, compact)).toBe(true) - expect(screen.getByRole('separator', { name: 'Context compacted' })).toBeInTheDocument() - }) - - it('draws a message waiting behind /compact after the compaction, never above it', () => { - const compactEntry = agentJournalSubmissionKey('cmd-1') - const commandTurn = agentJournalItemKey({ - provider: 'orca', - clientMessageId: 'command-turn:cmd-1' - }) - const held = agentJournalSubmissionKey('held') - const items = [ - ...fruitTurn(), - item(compactEntry, { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text: '/compact' }], - command: { name: 'compact' } - }), - settledTurn(commandTurn, compactEntry, 2, 10_000), - // Accepted while the command ran, so written before the command's result. - say(held, 'user', 'Say DONE'), - item( - 'command-result', - { kind: 'status', text: 'Conversation compacted.', presentation: 'compaction' }, - inTurn(commandTurn) - ) - ] - const submission = ( - clientMessageId: string, - handedOverAt?: number - ): AgentJournalSubmission => ({ - clientMessageId, - fence: 1, - payloadFingerprint: clientMessageId, - dispatchState: - handedOverAt === undefined && clientMessageId === 'held' ? 'pending' : 'accepted', - providerItemId: null, - reason: null, - submittedAt: 9_000, - resolvedAt: null, - handoverRecorded: true, - ...(handedOverAt !== undefined ? { handedOverAt } : {}) - }) - // The command settled; the loop has not yet handed the held message over. - renderJournal(items, [submission('cmd-1', 9_500), submission('held')]) - - expect( - follows( - screen.getByText('Say DONE'), - screen.getByRole('separator', { name: 'Context compacted' }) - ) - ).toBe(true) - }) - - it('keeps the row that says why a turn stopped visible in its folded turn', () => { - const items = [ - ...fruitTurn(), - item( - 'exit', - { kind: 'status', text: 'The agent exited unexpectedly.', tone: 'error' }, - inTurn('t1') - ) - ] - renderJournal(items) - - expect(screen.queryByText('Thinking about fruit.')).toBeNull() - expect(screen.getByText('The agent exited unexpectedly.')).toBeInTheDocument() - }) - - it('folds a turn the provider resumed on its own under its own status', () => { - const wake = agentJournalItemKey({ - provider: 'legacy', - agent: 'claude', - sessionId: 'session-1', - recordId: 'turn-lifecycle:wake' - }) - const items = [ - ...fruitTurn(), - // Claude keys a resumed turn's record on itself: no message opened it. - settledTurn(wake, wake, 9, 20_000), - say('wake-narration', 'assistant', 'The background build finished.', inTurn(wake)), - work('wake-work', inTurn(wake)), - say('wake-answer', 'assistant', 'All tests pass now.', inTurn(wake)) - ] - renderJournal(items) - - const previousStatus = screen.getByText('Worked for 4s') - const ownStatus = screen.getByText('Worked for 9s') - expect(follows(ownStatus, screen.getByText('Apple, banana, cherry.'))).toBe(true) - expect(follows(ownStatus, previousStatus)).toBe(true) - expect(screen.queryByText('The background build finished.')).toBeNull() - expect(screen.getByText('All tests pass now.')).toBeInTheDocument() - expect(screen.getAllByRole('button', { name: 'Toggle turn details' })).toHaveLength(2) - }) - - it("keeps the settled turn's duration while a turn the provider opened runs after it", () => { - const wake = agentJournalItemKey({ - provider: 'legacy', - agent: 'claude', - sessionId: 'session-1', - recordId: 'turn-lifecycle:wake' - }) - const items = [ - ...fruitTurn(), - item(wake, { - kind: 'turn', - turnId: wake, - state: 'running', - userItemId: wake, - startedAt: 20_000 - }), - say('wake-answer', 'assistant', 'Checking the background build.', inTurn(wake)) - ] - renderJournal(items, [], true) - - expect(screen.getByText('Worked for 4s')).toBeInTheDocument() - expect(screen.getByText('Checking the background build.')).toBeInTheDocument() - }) - - it('clocks a turn the provider opened under its own key, not the user turn before it', () => { - const wake = 'wake' - const ask = say('u1', 'user', 'List three fruits') - const answer = (state: 'running' | 'completed') => [ - ask, - item('t1', { kind: 'turn', turnId: 't1', state, userItemId: 'u1' }), - say('t1-answer', 'assistant', 'Apple, banana, cherry.', inTurn('t1')) - ] - // No host durations: this client's own clock is all the rows have. - const local = { hostTiming: false } - const { rerender } = render( - journalList(answer('running'), [], { - ...local, - isWorking: true, - workingStartedAt: Date.now() - 7_000 - }) - ) - rerender(journalList(answer('completed'), [], local)) - expect(screen.getByText('Worked for 7s')).toBeInTheDocument() - - const woken = [ - ...answer('completed'), - item(wake, { kind: 'turn', turnId: wake, state: 'running', userItemId: 'claude:wake' }), - say('wake-answer', 'assistant', 'Checking the background build.', inTurn(wake)) - ] - rerender(journalList(woken, [], { ...local, isWorking: true, workingStartedAt: Date.now() })) - expect(screen.getByText('Worked for 7s')).toBeInTheDocument() - }) - - it('draws a message waiting behind a running /compact after its live activity', () => { - const compactEntry = agentJournalSubmissionKey('cmd-1') - const commandTurn = agentJournalItemKey({ - provider: 'orca', - clientMessageId: 'command-turn:cmd-1' - }) - const held = agentJournalSubmissionKey('held') - const items = [ - ...fruitTurn(), - item(compactEntry, { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text: '/compact' }], - command: { name: 'compact' } - }), - item(commandTurn, { - kind: 'turn', - turnId: 'compact:cmd-1', - state: 'running', - userItemId: compactEntry, - startedAt: 10_000 - }), - say(held, 'user', 'Say DONE') - ] - const submission = ( - clientMessageId: string, - handedOverAt?: number - ): AgentJournalSubmission => ({ - clientMessageId, - fence: 1, - payloadFingerprint: clientMessageId, - dispatchState: handedOverAt === undefined ? 'pending' : 'accepted', - providerItemId: null, - reason: null, - submittedAt: 9_000, - resolvedAt: null, - handoverRecorded: true, - ...(handedOverAt !== undefined ? { handedOverAt } : {}) - }) - const { container } = render( - journalList(items, [submission('cmd-1', 9_500), submission('held')], { - isWorking: true, - workingStartedAt: 10_000 - }) ) - const activity = container.querySelector('[data-native-chat-turn-activity]') - expect(activity).not.toBeNull() - expect(follows(activity!, screen.getByText('/compact'))).toBe(true) - expect(follows(screen.getByText('Say DONE'), activity!)).toBe(true) - }) - - it.each(['settled', 'running'] as const)( - "a queued card Steered into a %s turn joins it, under the opener's bar, with no bar of its own", - (phase) => { - // Steer hands the draft over under a fresh submission id, linked by `queuedMessageId`; the - // host scopes its row to the turn it joined. - const steer = agentJournalSubmissionKey('handed-off') - const running = phase === 'running' + it("keeps a steer on its way into an ordinary running turn above that turn's activity", () => { + const steer = agentJournalSubmissionKey('steer') const items = [ say('u1', 'user', 'List three fruits'), - running - ? item('t1', { - kind: 'turn', - turnId: 't1', - state: 'running', - userItemId: 'u1', - startedAt: 1_000 - }) - : settledTurn('t1', 'u1', 4, 1_000), + item('t1', { + kind: 'turn', + turnId: 't1', + state: 'running', + userItemId: 'u1', + startedAt: 10_000 + }), say('t1-narration', 'assistant', 'Thinking about fruit.', inTurn('t1')), - say(steer, 'user', 'Make it four', inTurn('t1')), - say('t1-answer', 'assistant', 'Apple, banana, cherry, date.', inTurn('t1')) + say(steer, 'user', 'Make it four') ] const { container } = render( journalList( items, [ { - clientMessageId: 'handed-off', + clientMessageId: 'steer', fence: 1, payloadFingerprint: 'steer', - dispatchState: 'accepted', + dispatchState: 'pending', providerItemId: null, reason: null, - submittedAt: 2_000, - resolvedAt: 2_100, - queuedMessageId: 'draft-1' + submittedAt: 10_500, + resolvedAt: null, + handoverRecorded: true } ], - running ? { isWorking: true, workingStartedAt: 1_000 } : {} + { isWorking: true, workingStartedAt: 10_000 } ) ) - const bars = container.querySelectorAll('[data-native-chat-turn-status]') - expect(bars).toHaveLength(1) - const opener = screen.getByText('List three fruits') - const steered = screen.getByText('Make it four') - expect(follows(bars[0]!, opener)).toBe(true) - expect(follows(steered, bars[0]!)).toBe(true) - expect(follows(screen.getByText('Apple, banana, cherry, date.'), steered)).toBe(true) + // Handed over within moments, where it already is: it must not cross the activity line to wait. + const activity = container.querySelector('[data-native-chat-turn-activity]') + expect(activity).not.toBeNull() + expect(follows(activity!, screen.getByText('Make it four'))).toBe(true) + }) + + it('keeps a message whose own start is pending above the activity that start reports', () => { + const waiting = agentJournalSubmissionKey('first') + const { container } = render( + journalList( + [say(waiting, 'user', 'Hello there')], + [ + { + clientMessageId: 'first', + fence: 1, + payloadFingerprint: 'first', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 9_000, + resolvedAt: null, + handoverRecorded: true + } + ], + { isWorking: true, workingStartedAt: 9_000 } + ) + ) + + const activity = container.querySelector('[data-native-chat-turn-activity]') + expect(activity).not.toBeNull() + expect(follows(activity!, screen.getByText('Hello there'))).toBe(true) + }) + }) +} + +{ + describe('NativeChatMessageList host-settled turn timing', () => { + afterEach(cleanup) + + let restoreViewport = (): void => {} + + beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() + }) + + afterAll(() => restoreViewport()) + + const session: NativeChatLiveSession = { + messages: [ + { + id: 'user-settled', + role: 'user', + blocks: [{ type: 'text', text: 'Settled on the host' }], + timestamp: 1, + source: 'transcript' + }, + { + id: 'assistant-settled', + role: 'assistant', + blocks: [{ type: 'text', text: 'Done.' }], + timestamp: 2, + source: 'transcript' + } + ], + status: 'ready', + sessionId: 'session-1', + agent: 'codex', + hasMore: false, + loadingEarlier: false, + olderHistoryGeneration: 0, + loadEarlier: vi.fn(), + readPhase: 'ready' } - ) - it("keeps a steer on its way into an ordinary running turn above that turn's activity", () => { - const steer = agentJournalSubmissionKey('steer') - const items = [ - say('u1', 'user', 'List three fruits'), - item('t1', { - kind: 'turn', - turnId: 't1', - state: 'running', - userItemId: 'u1', - startedAt: 10_000 - }), - say('t1-narration', 'assistant', 'Thinking about fruit.', inTurn('t1')), - say(steer, 'user', 'Make it four') - ] - const { container } = render( - journalList( - items, - [ - { - clientMessageId: 'steer', - fence: 1, - payloadFingerprint: 'steer', - dispatchState: 'pending', - providerItemId: null, - reason: null, - submittedAt: 10_500, - resolvedAt: null, - handoverRecorded: true - } - ], - { isWorking: true, workingStartedAt: 10_000 } - ) - ) + const settledTurns = new Map([['user-settled', { startedAt: 1, workedSeconds: 197 }]]) - // Handed over within moments, where it already is: it must not cross the activity line to wait. - const activity = container.querySelector('[data-native-chat-turn-activity]') - expect(activity).not.toBeNull() - expect(follows(activity!, screen.getByText('Make it four'))).toBe(true) + it('does not render a local completed duration when the host cannot verify the end', () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(1_000) + const unknownTurns = new Map([['user-settled', null]]) + try { + const { rerender } = render( + + ) + now.mockReturnValue(60_000) + rerender( + + ) + expect(screen.queryByText(/Worked for/)).not.toBeInTheDocument() + } finally { + now.mockRestore() + } + }) + + it('renders a host-settled duration without ever clocking the turn locally', () => { + // A local clock nowhere near the host's: the value must still be the host's. + const now = vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000) + try { + const { rerender } = render( + + ) + expect(screen.getByText('Worked for 3m 17s')).toBeInTheDocument() + now.mockReturnValue(1_700_000_099_000) + rerender( + + ) + expect(screen.getByText('Worked for 3m 17s')).toBeInTheDocument() + } finally { + now.mockRestore() + } + }) }) - it('keeps a message whose own start is pending above the activity that start reports', () => { - const waiting = agentJournalSubmissionKey('first') - const { container } = render( - journalList( - [say(waiting, 'user', 'Hello there')], - [ - { - clientMessageId: 'first', - fence: 1, - payloadFingerprint: 'first', - dispatchState: 'pending', - providerItemId: null, - reason: null, - submittedAt: 9_000, - resolvedAt: null, - handoverRecorded: true - } - ], - { isWorking: true, workingStartedAt: 9_000 } - ) - ) + describe('a live turn whose record and opening message are not loaded', () => { + let restoreViewport = (): void => {} + beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() + }) + afterAll(() => restoreViewport()) + afterEach(cleanup) - const activity = container.querySelector('[data-native-chat-turn-activity]') - expect(activity).not.toBeNull() - expect(follows(activity!, screen.getByText('Hello there'))).toBe(true) + const TURN_RECORD = 'turn-record-1' + const IN_TURN: AgentJournalTurnScope = { kind: 'turn', turnItemId: TURN_RECORD } + + /** The newest rows of the turn: everything from its 300th row on. */ + const loaded: AgentJournalRenderItem[] = [300, 301, 302].map((sequence) => ({ + itemId: `row-${sequence}`, + revision: 0, + sequence, + observedAt: sequence, + body: { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: `Step ${sequence}` }] + }, + turnScope: IN_TURN + })) + + const patch = '@@ -1 +1 @@\n-before\n+after' + /** An edit among the loaded rows; the turn's earlier edits are above them. */ + const edit: AgentJournalRenderItem = { + itemId: 'row-303', + revision: 0, + sequence: 303, + observedAt: 303, + body: { + kind: 'diff', + path: 'src/a.ts', + patch: { head: patch, truncated: false, digest: 'fixture', byteLength: patch.length } + }, + turnScope: IN_TURN + } + + const running: AgentSessionLatestTurn = { + itemId: TURN_RECORD, + observedAt: 1, + turn: { turnId: 'turn-1', state: 'running', startedAt: 1_000, userItemId: 'user-1' } + } + + function list( + latestTurn: AgentSessionLatestTurn | null | undefined, + items: AgentJournalRenderItem[] = loaded + ): React.JSX.Element { + return ( + + ) + } + + it('draws its Working bar with the running clock', () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(64_000) + try { + render(list(running)) + expect(screen.getByText('Step 302')).toBeInTheDocument() + expect(screen.getByText(/Working for 1m 3s/)).toBeInTheDocument() + } finally { + now.mockRestore() + } + }) + + it('totals no edits, since only the end of the turn is loaded', () => { + render(list(running, [...loaded, edit])) + expect(screen.getByText(/Working for/)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /changed file/ })).toBeNull() + }) + + it('had no bar to draw from the loaded rows alone', () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(64_000) + try { + render(list(undefined)) + expect(screen.getByText('Step 302')).toBeInTheDocument() + expect(screen.queryByText(/Working for/)).not.toBeInTheDocument() + } finally { + now.mockRestore() + } + }) }) -}) + + describe('a thought joining a work run', () => { + let restoreViewport = (): void => {} + beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() + }) + afterAll(() => restoreViewport()) + afterEach(cleanup) + + const STARTED = 1_000 + + const prompt: NativeChatMessage = { + id: 'user-1', + role: 'user', + blocks: [{ type: 'text', text: 'Start the task' }], + timestamp: STARTED - 500, + source: 'transcript' + } + + const command: NativeChatMessage = { + id: 'tool-1', + role: 'assistant', + blocks: [ + { type: 'tool-call', name: 'Bash', input: { command: 'ls logs' }, state: 'completed' } + ], + timestamp: STARTED, + source: 'transcript' + } + + function reasoning(state: 'running' | 'completed'): NativeChatMessage { + return { + id: 'r-1', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Weighing two approaches' }], + timestamp: STARTED + 50, + source: 'transcript', + state, + ...(state === 'completed' ? { completedAt: STARTED + 12_000 } : {}) + } + } + + /** The journal that says the turn runs and what its newest content is. */ + function journal(rows: readonly NativeChatMessage[]): AgentJournalRenderItem[] { + return [ + { + itemId: prompt.id, + revision: 1, + sequence: 1, + observedAt: 1, + body: { kind: 'message', role: 'user', blocks: prompt.blocks } + }, + { + itemId: 'turn-1', + revision: 1, + sequence: 2, + observedAt: 2, + body: { kind: 'turn', turnId: 'turn-1', state: 'running', userItemId: prompt.id } + }, + ...rows.map((row, index) => ({ + itemId: row.id, + revision: 1, + sequence: index + 3, + observedAt: index + 3, + body: { + kind: 'message' as const, + role: row.role, + blocks: row.blocks, + ...(row.state ? { state: row.state } : {}) + } + })) + ] + } + + function list( + rows: readonly NativeChatMessage[], + props: Partial> = {} + ): React.JSX.Element { + const session: NativeChatLiveSession = { + messages: [prompt, ...rows], + status: 'working', + sessionId: 'session-1', + agent: 'claude', + hasMore: false, + loadingEarlier: false, + olderHistoryGeneration: 0, + loadEarlier: vi.fn(), + readPhase: 'ready' + } + return ( + + ) + } + + const runHeader = (): HTMLElement => + document.querySelector('[data-native-chat-tool-run-state]')! + + it('joins collapsed when the reader never opened it', () => { + render(list([command, reasoning('completed')])) + expect(runHeader()).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByText('Weighing two approaches')).toBeNull() + }) + + // The run never opens for a thought; the thought keeps its own open state inside it. + it('keeps a thought the reader opened live open inside its collapsed run', () => { + const { rerender } = render(list([command, reasoning('running')])) + fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) + expect(screen.getByText('Weighing two approaches')).toBeInTheDocument() + + rerender(list([command, reasoning('completed')])) + expect(runHeader()).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByText('Weighing two approaches')).toBeNull() + + fireEvent.click(runHeader()) + expect(screen.getByRole('button', { name: /Thought for/ })).toHaveAttribute( + 'aria-expanded', + 'true' + ) + expect(screen.getByText('Weighing two approaches')).toBeInTheDocument() + }) + + // While the live line is not showing it (a Stop in flight, a prompt the reader owes), the open + // thought sits in the run rather than flashing as its own row until the turn ends. + it.each([ + ['a Stop is in flight', { stopping: true }], + ['a prompt waits on the reader', { awaitingInput: 'unshown' as const }] + ])('keeps an open thought inside the run while %s', (_, props) => { + const second: NativeChatMessage = { + ...command, + id: 'tool-2', + blocks: [{ type: 'tool-call', name: 'Bash', input: { command: 'ls' }, state: 'completed' }], + timestamp: STARTED + 100 + } + const open = { ...reasoning('running'), id: 'r-2', timestamp: STARTED + 200 } + render(list([command, reasoning('completed'), second, open], props)) + expect(screen.queryByRole('button', { name: /Reasoning|Thought/ })).toBeNull() + fireEvent.click(runHeader()) + expect(screen.getAllByRole('button', { name: /Reasoning|Thought/ })).toHaveLength(2) + }) + }) +} diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-timing.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-timing.test.tsx deleted file mode 100644 index 63fff5ee377..00000000000 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-timing.test.tsx +++ /dev/null @@ -1,362 +0,0 @@ -// @vitest-environment happy-dom - -import '@testing-library/jest-dom/vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' -import type { NativeChatLiveSession } from './use-native-chat-live-session' -import type { - AgentJournalRenderItem, - AgentJournalTurnScope -} from '../../../../shared/agent-session-journal-types' -import type { AgentSessionLatestTurn } from '../../../../shared/agent-session-wire' -import type { NativeChatMessage } from '../../../../shared/native-chat-types' -import { projectStructuredAgentSessionMessages } from '../../../../shared/structured-agent-session-message-projection' -import { NativeChatMessageList } from './NativeChatMessageList' -import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' - -describe('NativeChatMessageList host-settled turn timing', () => { - afterEach(cleanup) - - let restoreViewport = (): void => {} - - beforeAll(() => { - restoreViewport = installNativeChatMessageListTestViewport() - }) - - afterAll(() => restoreViewport()) - - const session: NativeChatLiveSession = { - messages: [ - { - id: 'user-settled', - role: 'user', - blocks: [{ type: 'text', text: 'Settled on the host' }], - timestamp: 1, - source: 'transcript' - }, - { - id: 'assistant-settled', - role: 'assistant', - blocks: [{ type: 'text', text: 'Done.' }], - timestamp: 2, - source: 'transcript' - } - ], - status: 'ready', - sessionId: 'session-1', - agent: 'codex', - hasMore: false, - loadingEarlier: false, - olderHistoryGeneration: 0, - loadEarlier: vi.fn(), - readPhase: 'ready' - } - - const settledTurns = new Map([['user-settled', { startedAt: 1, workedSeconds: 197 }]]) - - it('does not render a local completed duration when the host cannot verify the end', () => { - const now = vi.spyOn(Date, 'now').mockReturnValue(1_000) - const unknownTurns = new Map([['user-settled', null]]) - try { - const { rerender } = render( - - ) - now.mockReturnValue(60_000) - rerender( - - ) - expect(screen.queryByText(/Worked for/)).not.toBeInTheDocument() - } finally { - now.mockRestore() - } - }) - - it('renders a host-settled duration without ever clocking the turn locally', () => { - // A local clock nowhere near the host's: the value must still be the host's. - const now = vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000) - try { - const { rerender } = render( - - ) - expect(screen.getByText('Worked for 3m 17s')).toBeInTheDocument() - now.mockReturnValue(1_700_000_099_000) - rerender( - - ) - expect(screen.getByText('Worked for 3m 17s')).toBeInTheDocument() - } finally { - now.mockRestore() - } - }) -}) - -describe('a live turn whose record and opening message are not loaded', () => { - let restoreViewport = (): void => {} - beforeAll(() => { - restoreViewport = installNativeChatMessageListTestViewport() - }) - afterAll(() => restoreViewport()) - afterEach(cleanup) - - const TURN_RECORD = 'turn-record-1' - const IN_TURN: AgentJournalTurnScope = { kind: 'turn', turnItemId: TURN_RECORD } - - /** The newest rows of the turn: everything from its 300th row on. */ - const loaded: AgentJournalRenderItem[] = [300, 301, 302].map((sequence) => ({ - itemId: `row-${sequence}`, - revision: 0, - sequence, - observedAt: sequence, - body: { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: `Step ${sequence}` }] - }, - turnScope: IN_TURN - })) - - const patch = '@@ -1 +1 @@\n-before\n+after' - /** An edit among the loaded rows; the turn's earlier edits are above them. */ - const edit: AgentJournalRenderItem = { - itemId: 'row-303', - revision: 0, - sequence: 303, - observedAt: 303, - body: { - kind: 'diff', - path: 'src/a.ts', - patch: { head: patch, truncated: false, digest: 'fixture', byteLength: patch.length } - }, - turnScope: IN_TURN - } - - const running: AgentSessionLatestTurn = { - itemId: TURN_RECORD, - observedAt: 1, - turn: { turnId: 'turn-1', state: 'running', startedAt: 1_000, userItemId: 'user-1' } - } - - function list( - latestTurn: AgentSessionLatestTurn | null | undefined, - items: AgentJournalRenderItem[] = loaded - ): React.JSX.Element { - return ( - - ) - } - - it('draws its Working bar with the running clock', () => { - const now = vi.spyOn(Date, 'now').mockReturnValue(64_000) - try { - render(list(running)) - expect(screen.getByText('Step 302')).toBeInTheDocument() - expect(screen.getByText(/Working for 1m 3s/)).toBeInTheDocument() - } finally { - now.mockRestore() - } - }) - - it('totals no edits, since only the end of the turn is loaded', () => { - render(list(running, [...loaded, edit])) - expect(screen.getByText(/Working for/)).toBeInTheDocument() - expect(screen.queryByRole('button', { name: /changed file/ })).toBeNull() - }) - - it('had no bar to draw from the loaded rows alone', () => { - const now = vi.spyOn(Date, 'now').mockReturnValue(64_000) - try { - render(list(undefined)) - expect(screen.getByText('Step 302')).toBeInTheDocument() - expect(screen.queryByText(/Working for/)).not.toBeInTheDocument() - } finally { - now.mockRestore() - } - }) -}) - -describe('a thought joining a work run', () => { - let restoreViewport = (): void => {} - beforeAll(() => { - restoreViewport = installNativeChatMessageListTestViewport() - }) - afterAll(() => restoreViewport()) - afterEach(cleanup) - - const STARTED = 1_000 - - const prompt: NativeChatMessage = { - id: 'user-1', - role: 'user', - blocks: [{ type: 'text', text: 'Start the task' }], - timestamp: STARTED - 500, - source: 'transcript' - } - - const command: NativeChatMessage = { - id: 'tool-1', - role: 'assistant', - blocks: [ - { type: 'tool-call', name: 'Bash', input: { command: 'ls logs' }, state: 'completed' } - ], - timestamp: STARTED, - source: 'transcript' - } - - function reasoning(state: 'running' | 'completed'): NativeChatMessage { - return { - id: 'r-1', - role: 'reasoning', - blocks: [{ type: 'text', text: 'Weighing two approaches' }], - timestamp: STARTED + 50, - source: 'transcript', - state, - ...(state === 'completed' ? { completedAt: STARTED + 12_000 } : {}) - } - } - - /** The journal that says the turn runs and what its newest content is. */ - function journal(rows: readonly NativeChatMessage[]): AgentJournalRenderItem[] { - return [ - { - itemId: prompt.id, - revision: 1, - sequence: 1, - observedAt: 1, - body: { kind: 'message', role: 'user', blocks: prompt.blocks } - }, - { - itemId: 'turn-1', - revision: 1, - sequence: 2, - observedAt: 2, - body: { kind: 'turn', turnId: 'turn-1', state: 'running', userItemId: prompt.id } - }, - ...rows.map((row, index) => ({ - itemId: row.id, - revision: 1, - sequence: index + 3, - observedAt: index + 3, - body: { - kind: 'message' as const, - role: row.role, - blocks: row.blocks, - ...(row.state ? { state: row.state } : {}) - } - })) - ] - } - - function list( - rows: readonly NativeChatMessage[], - props: Partial> = {} - ): React.JSX.Element { - const session: NativeChatLiveSession = { - messages: [prompt, ...rows], - status: 'working', - sessionId: 'session-1', - agent: 'claude', - hasMore: false, - loadingEarlier: false, - olderHistoryGeneration: 0, - loadEarlier: vi.fn(), - readPhase: 'ready' - } - return ( - - ) - } - - const runHeader = (): HTMLElement => - document.querySelector('[data-native-chat-tool-run-state]')! - - it('joins collapsed when the reader never opened it', () => { - render(list([command, reasoning('completed')])) - expect(runHeader()).toHaveAttribute('aria-expanded', 'false') - expect(screen.queryByText('Weighing two approaches')).toBeNull() - }) - - // The run never opens for a thought; the thought keeps its own open state inside it. - it('keeps a thought the reader opened live open inside its collapsed run', () => { - const { rerender } = render(list([command, reasoning('running')])) - fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) - expect(screen.getByText('Weighing two approaches')).toBeInTheDocument() - - rerender(list([command, reasoning('completed')])) - expect(runHeader()).toHaveAttribute('aria-expanded', 'false') - expect(screen.queryByText('Weighing two approaches')).toBeNull() - - fireEvent.click(runHeader()) - expect(screen.getByRole('button', { name: /Thought for/ })).toHaveAttribute( - 'aria-expanded', - 'true' - ) - expect(screen.getByText('Weighing two approaches')).toBeInTheDocument() - }) - - // While the live line is not showing it (a Stop in flight, a prompt the reader owes), the open - // thought sits in the run rather than flashing as its own row until the turn ends. - it.each([ - ['a Stop is in flight', { stopping: true }], - ['a prompt waits on the reader', { awaitingInput: 'unshown' as const }] - ])('keeps an open thought inside the run while %s', (_, props) => { - const second: NativeChatMessage = { - ...command, - id: 'tool-2', - blocks: [{ type: 'tool-call', name: 'Bash', input: { command: 'ls' }, state: 'completed' }], - timestamp: STARTED + 100 - } - const open = { ...reasoning('running'), id: 'r-2', timestamp: STARTED + 200 } - render(list([command, reasoning('completed'), second, open], props)) - expect(screen.queryByRole('button', { name: /Reasoning|Thought/ })).toBeNull() - fireEvent.click(runHeader()) - expect(screen.getAllByRole('button', { name: /Reasoning|Thought/ })).toHaveLength(2) - }) -}) diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.appearance.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.appearance.test.tsx index 89d3fb9941b..f7d2cff4d24 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.appearance.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.appearance.test.tsx @@ -1,109 +1,628 @@ // @vitest-environment happy-dom + import '@testing-library/jest-dom/vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it } from 'vitest' + +import { cleanup, fireEvent, render, screen, act } from '@testing-library/react' + +import { afterEach, describe, expect, it, vi } from 'vitest' + import type { NativeChatBlock } from '../../../../shared/native-chat-types' + import { NativeChatToolRun } from './NativeChatToolRun' + import { nativeChatAppearanceStyle } from './native-chat-appearance-style' + import { openToolRunMembers } from './native-chat-tool-run-members-test-support' -afterEach(cleanup) +import { + NativeChatDisclosureContext, + useNativeChatDisclosures +} from './native-chat-disclosure-store' -const blocks: NativeChatBlock[] = [ - { type: 'tool-call', name: 'Bash', input: { command: 'test' }, state: 'failed' }, - { type: 'tool-result', output: 'exit 1', isError: true }, - { type: 'tool-call', name: 'Read', input: { file_path: 'a.ts' }, state: 'completed' }, - { type: 'tool-call', name: 'Write', input: { file_path: 'a.ts' }, state: 'completed' }, - { type: 'tool-call', name: 'Grep', input: { pattern: 'todo' }, state: 'completed' }, - { type: 'tool-call', name: 'Task', input: { description: 'Review' }, state: 'completed' } -] +import { revealNativeChatToolRunMember } from './NativeChatToolRunMembers' -describe('tool-run summary in a matching chat', () => { - it.each([false, true])('caps the summary at two wrapped lines, live=%s', (live) => { - const style = nativeChatAppearanceStyle({ - terminalFontFamily: 'Menlo', - nativeChatAppearance: { matchTerminalInterface: true } - }) - const { container } = render( -
-
- +import { i18n } from '@/i18n/i18n' + +{ + const blocks: NativeChatBlock[] = [ + { type: 'tool-call', name: 'Bash', input: { command: 'test' }, state: 'failed' }, + { type: 'tool-result', output: 'exit 1', isError: true }, + { type: 'tool-call', name: 'Read', input: { file_path: 'a.ts' }, state: 'completed' }, + { type: 'tool-call', name: 'Write', input: { file_path: 'a.ts' }, state: 'completed' }, + { type: 'tool-call', name: 'Grep', input: { pattern: 'todo' }, state: 'completed' }, + { type: 'tool-call', name: 'Task', input: { description: 'Review' }, state: 'completed' } + ] + + describe('tool-run summary in a matching chat', () => { + afterEach(cleanup) + it.each([false, true])('caps the summary at two wrapped lines, live=%s', (live) => { + const style = nativeChatAppearanceStyle({ + terminalFontFamily: 'Menlo', + nativeChatAppearance: { matchTerminalInterface: true } + }) + const { container } = render( +
+
+ +
-
- ) - expect(style['--chat-content-max-width']).toBe('46rem') - expect(style['--chat-font-family']).toContain('Menlo') - const summary = container.querySelector('span.native-chat-message-text') - expect(summary).toHaveTextContent(live ? 'running 1 agent' : 'ran 1 agent') - expect(summary).toHaveClass('min-w-0', 'line-clamp-2', 'whitespace-normal', 'break-words') - expect(summary).not.toHaveClass('truncate', 'whitespace-nowrap', 'font-mono') - const failure = container.querySelector('[aria-label="Failed tool calls: 1"]') - expect(failure).toHaveTextContent('1 failed') - expect(failure).toHaveClass('shrink-0') - expect(failure?.parentElement).toHaveClass('flex', 'h-[1lh]', 'items-center') - }) - - it('keeps a long command available in the expanded detail', () => { - const command = `printf ${'x'.repeat(5000)}` - const style = nativeChatAppearanceStyle({ - terminalFontFamily: 'Menlo', - nativeChatAppearance: { matchTerminalInterface: true } + ) + expect(style['--chat-content-max-width']).toBe('46rem') + expect(style['--chat-font-family']).toContain('Menlo') + const summary = container.querySelector('span.native-chat-message-text') + expect(summary).toHaveTextContent(live ? 'running 1 agent' : 'ran 1 agent') + expect(summary).toHaveClass('min-w-0', 'line-clamp-2', 'whitespace-normal', 'break-words') + expect(summary).not.toHaveClass('truncate', 'whitespace-nowrap', 'font-mono') + const failure = container.querySelector('[aria-label="Failed tool calls: 1"]') + expect(failure).toHaveTextContent('1 failed') + expect(failure).toHaveClass('shrink-0') + expect(failure?.parentElement).toHaveClass('flex', 'h-[1lh]', 'items-center') }) - const { container } = render( -
+ + it('keeps a long command available in the expanded detail', () => { + const command = `printf ${'x'.repeat(5000)}` + const style = nativeChatAppearanceStyle({ + terminalFontFamily: 'Menlo', + nativeChatAppearance: { matchTerminalInterface: true } + }) + const { container } = render( +
+ +
+ ) + + const summary = container.querySelector('span.native-chat-message-text') + expect(summary).toHaveClass('line-clamp-2', 'break-words') + expect(summary?.textContent).toContain('printf') + expect(summary?.textContent?.length).toBeLessThan(command.length) + expect(container.querySelector('pre')).toBeNull() + + fireEvent.click(screen.getByRole('button')) + openToolRunMembers() + + expect(container.querySelector('pre')).toHaveTextContent(command) + }) + + it.each([ + ['wrapped', `QA inert command for display only: ${'x'.repeat(400)}`], + ['single-line', 'pnpm test'] + ])('pins the marks to the first line of a %s summary', (_summaryLength, command) => { + const { container } = render( -
- ) + ) - const summary = container.querySelector('span.native-chat-message-text') - expect(summary).toHaveClass('line-clamp-2', 'break-words') - expect(summary?.textContent).toContain('printf') - expect(summary?.textContent?.length).toBeLessThan(command.length) - expect(container.querySelector('pre')).toBeNull() + const header = screen.getByRole('button') + // Top-aligned in the summary's own type, so `1lh` is one summary line. + expect(header).toHaveClass('items-start', 'text-sm', 'leading-relaxed') + expect(header).toHaveClass('native-chat-message-text') + expect(header).not.toHaveClass('items-center') + const summary = container.querySelector('span.native-chat-message-text') + expect(summary?.parentElement).toBe(header) + expect(summary).toHaveClass('min-w-0', 'line-clamp-2') - fireEvent.click(screen.getByRole('button')) - openToolRunMembers() - - expect(container.querySelector('pre')).toHaveTextContent(command) + const slots = Array.from(header.children).filter((child) => child !== summary) + expect(slots.length).toBeGreaterThanOrEqual(2) + for (const slot of slots) { + expect(slot).toHaveClass('flex', 'h-[1lh]', 'items-center') + } + // The icon leads, so a wrapped second line starts under the text, not under it. + expect(header.firstElementChild?.querySelector('svg')).toBeInTheDocument() + expect(header.children[1]).toBe(summary) + expect(header.querySelector('.lucide-check')?.parentElement).toHaveClass('h-[1lh]') + // The header carries no caret: the whole line is the toggle. + expect(header.querySelector('.lucide-chevron-right')).toBeNull() + }) }) +} - it.each([ - ['wrapped', `QA inert command for display only: ${'x'.repeat(400)}`], - ['single-line', 'pnpm test'] - ])('pins the marks to the first line of a %s summary', (_summaryLength, command) => { - const { container } = render( - +{ + const QUESTION = 'What would you like me to do next in this repo?' + const ASK_INPUT = { questions: [{ question: QUESTION }] } + + function askBlocks(state: 'running' | 'completed'): NativeChatBlock[] { + return [{ type: 'tool-call', name: 'AskUserQuestion', input: ASK_INPUT, state }] + } + + /** Lays every element out wider than its box, as a long question is on its line. */ + function clipEveryLine(): () => void { + const originals = ['scrollWidth', 'clientWidth'].map( + (name) => [name, Object.getOwnPropertyDescriptor(HTMLElement.prototype, name)] as const ) - - const header = screen.getByRole('button') - // Top-aligned in the summary's own type, so `1lh` is one summary line. - expect(header).toHaveClass('items-start', 'text-sm', 'leading-relaxed') - expect(header).toHaveClass('native-chat-message-text') - expect(header).not.toHaveClass('items-center') - const summary = container.querySelector('span.native-chat-message-text') - expect(summary?.parentElement).toBe(header) - expect(summary).toHaveClass('min-w-0', 'line-clamp-2') - - const slots = Array.from(header.children).filter((child) => child !== summary) - expect(slots.length).toBeGreaterThanOrEqual(2) - for (const slot of slots) { - expect(slot).toHaveClass('flex', 'h-[1lh]', 'items-center') + Object.defineProperty(HTMLElement.prototype, 'scrollWidth', { + configurable: true, + get: () => 400 + }) + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { + configurable: true, + get: () => 100 + }) + return () => { + for (const [name, original] of originals) { + if (original) { + Object.defineProperty(HTMLElement.prototype, name, original) + } else { + Reflect.deleteProperty(HTMLElement.prototype, name) + } + } } - // The icon leads, so a wrapped second line starts under the text, not under it. - expect(header.firstElementChild?.querySelector('svg')).toBeInTheDocument() - expect(header.children[1]).toBe(summary) - expect(header.querySelector('.lucide-check')?.parentElement).toHaveClass('h-[1lh]') - // The header carries no caret: the whole line is the toggle. - expect(header.querySelector('.lucide-chevron-right')).toBeNull() + } + + function DisclosureHarness({ mounted }: { mounted: boolean }): React.JSX.Element { + const disclosures = useNativeChatDisclosures() + return ( + + {mounted ? ( + + ) : null} + + ) + } + + describe('NativeChatToolRun awaiting-input row', () => { + afterEach(cleanup) + it('does not revive stale tool state after a turn stops', () => { + render( + + ) + expect(screen.queryByText('Awaiting user input:')).toBeNull() + expect(screen.getByText('Asked:')).toBeInTheDocument() + }) + + it('keeps a pending question visible alongside another active tool', () => { + render( + + ) + expect(screen.getByText('Awaiting user input:')).toBeInTheDocument() + expect(screen.getByText('Reading 1 file')).toBeInTheDocument() + expect(screen.getByText('Read a.ts')).toBeInTheDocument() + }) + + it('preserves errors from failed question calls', () => { + render( + + ) + openToolRunMembers() + expect(screen.queryByText('Awaiting user input:')).toBeNull() + expect(screen.queryByText('Asked:')).toBeNull() + expect(screen.getAllByText('Question rejected').length).toBeGreaterThan(0) + }) + it('replaces a running ask call with the awaiting row', () => { + const { container } = render( + + ) + + expect(screen.getByText('Awaiting user input:')).toHaveClass( + 'animate-pulse', + 'motion-reduce:animate-none' + ) + expect(screen.getByText(QUESTION)).toBeInTheDocument() + expect(container.querySelector('.lucide-message-square-more')).toBeInTheDocument() + // The raw call and its payload are exactly what this row exists to replace. + expect(screen.queryByText(/Running AskUserQuestion/)).toBeNull() + expect(screen.queryByText(/AskUserQuestion/)).toBeNull() + }) + + it('reports a settled ask without the pulse or a tool-count header', () => { + const { container } = render( + + ) + + expect(screen.getByText('Asked:')).not.toHaveClass('animate-pulse') + expect(screen.getByText(QUESTION)).toBeInTheDocument() + // A run that is only the ask has no work left to head, so it draws no header. + expect(container.querySelector('[data-native-chat-tool-run-state]')).toBeNull() + }) + + it('leaves a question that fits on its line as plain text', () => { + const { container } = render( + + ) + expect(screen.getByText(QUESTION)).toHaveClass('truncate') + expect(container.querySelector('button')).toBeNull() + }) + + it('opens a clipped question below the toggle and folds it back', () => { + const restore = clipEveryLine() + try { + render( + + ) + const toggle = screen.getByRole('button', { name: /Asked:/ }) + expect(toggle).toHaveAttribute('aria-expanded', 'false') + + fireEvent.click(toggle) + expect(toggle).toHaveAttribute('aria-expanded', 'true') + const full = screen.getByText(QUESTION) + expect(full).not.toHaveClass('truncate') + // Outside the button, so it selects like prose and a click in it keeps it open. + expect(full.closest('button')).toBeNull() + fireEvent.click(full) + expect(toggle).toHaveAttribute('aria-expanded', 'true') + + fireEvent.click(toggle) + expect(toggle).toHaveAttribute('aria-expanded', 'false') + expect(screen.getByText(QUESTION)).toHaveClass('truncate') + } finally { + restore() + } + }) + + it('keeps an opened question open when the windowed row remounts', () => { + const restore = clipEveryLine() + try { + const { rerender } = render() + fireEvent.click(screen.getByRole('button', { name: /Asked:/ })) + + rerender() + expect(screen.queryByText(QUESTION)).toBeNull() + rerender() + + const toggle = screen.getByRole('button', { name: /Asked:/ }) + expect(toggle).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByText(QUESTION).closest('button')).toBeNull() + + // Folding it keeps the same control, so keyboard focus is not dropped. + toggle.focus() + fireEvent.click(toggle) + expect(document.activeElement).toBe(toggle) + expect(toggle).toHaveAttribute('aria-expanded', 'false') + } finally { + restore() + } + }) + + it('offers no expansion when the row names only a question count', () => { + const restore = clipEveryLine() + try { + const input = { questions: [{ question: 'First?' }, { question: 'Second?' }] } + render( + + ) + expect(screen.getByText('2 questions')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Asked:/ })).toBeNull() + } finally { + restore() + } + }) + + it('counts only the work that ran in the header beside the ask', () => { + const blocks: NativeChatBlock[] = [ + { type: 'tool-call', name: 'Read', input: { file_path: 'a.ts' }, state: 'completed' }, + { type: 'tool-call', name: 'AskUserQuestion', input: ASK_INPUT, state: 'running' } + ] + + render() + + expect(screen.getByText('Awaiting user input:')).toBeInTheDocument() + // One call ran; being asked a question is not work to summarize. The agent + // is blocked on the reader, so the run reads settled, not in progress. + expect(screen.getByText('Read 1 file')).toBeInTheDocument() + expect(screen.queryByText('Reading 1 file')).toBeNull() + }) + + it('draws the row from the tool name when the payload names no question', () => { + render( + + ) + + expect(screen.getByText('Awaiting user input')).toBeInTheDocument() + expect(screen.queryByText(/request_user_input/)).toBeNull() + }) }) -}) +} + +{ + const VIEWPORT = 200 + + function calls(count: number): NativeChatBlock[] { + return Array.from({ length: count }, (_, index) => ({ + type: 'tool-call' as const, + name: 'Read', + callId: `call-${index}`, + input: `{"file_path":"src/file-${index}.ts"}` + })) + } + + /** happy-dom lays nothing out, so the box's geometry is whatever the test says it is. */ + function layOut(contentHeight: { current: number }): void { + vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation( + () => contentHeight.current + ) + vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(VIEWPORT) + } + + function membersBox(container: HTMLElement): HTMLElement { + const box = container.querySelector('[data-native-chat-tool-run-members]') + if (!box) { + throw new Error('members box did not render') + } + return box + } + + /** A live run inside the transcript's disclosure store, which is what remembers its box. */ + function Transcript({ + mounted = true, + count = 3 + }: { + mounted?: boolean + count?: number + }): React.JSX.Element { + const disclosures = useNativeChatDisclosures() + return ( + + {mounted ? ( + + ) : null} + + ) + } + + describe('NativeChatToolRun members box', () => { + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + it('keeps a live run on its newest call until the reader scrolls away', () => { + const contentHeight = { current: 600 } + layOut(contentHeight) + const run = (count: number): React.JSX.Element => ( + + ) + const { container, rerender } = render(run(3)) + const box = membersBox(container) + expect(box.scrollTop).toBe(600) + + contentHeight.current = 900 + rerender(run(4)) + expect(box.scrollTop).toBe(900) + + box.scrollTop = 100 + fireEvent.scroll(box) + contentHeight.current = 1200 + rerender(run(5)) + expect(box.scrollTop).toBe(100) + + box.scrollTop = 1200 - VIEWPORT + fireEvent.scroll(box) + contentHeight.current = 1500 + rerender(run(6)) + expect(box.scrollTop).toBe(1500) + }) + + it('comes back where the reader left it after windowing unmounts the row', () => { + const contentHeight = { current: 900 } + layOut(contentHeight) + const { container, rerender } = render() + const box = membersBox(container) + box.scrollTop = 100 + fireEvent.scroll(box) + + rerender() + contentHeight.current = 1200 + rerender() + + // Neither back at the newest call nor following again: both were the reader's choice. + expect(membersBox(container).scrollTop).toBe(100) + }) + + it('forgets its place when the reader closes it', () => { + layOut({ current: 900 }) + const { container } = render() + const box = membersBox(container) + box.scrollTop = 100 + fireEvent.scroll(box) + + const header = container.querySelector('[data-native-chat-tool-run-state]')! + fireEvent.click(header) + fireEvent.click(header) + + // A live run the reader reopens shows what it is doing now. + expect(membersBox(container).scrollTop).toBe(900) + }) + + it('opens a settled run at its first call', () => { + layOut({ current: 600 }) + const { container } = render( + + ) + expect(membersBox(container).scrollTop).toBe(0) + }) + + it('brings a revealed member to the top of the box and reports the box', () => { + layOut({ current: 600 }) + const { container } = render( + + ) + const box = membersBox(container) + const member = box.querySelectorAll('button')[2]! + vi.spyOn(box, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 40, 300, VIEWPORT)) + vi.spyOn(member, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 340, 300, 26)) + + expect(revealNativeChatToolRunMember(member)).toBe(box) + expect(box.scrollTop).toBe(300) + }) + }) + + describe('NativeChatToolRun asides', () => { + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + it('draws the rows it is handed among its calls, in order', () => { + const blocks = calls(2) + const { container } = render( + between

]]]), + after: [

last

] + }} + expandSignal + activeTurnIsWorking={false} + /> + ) + const rows = [...membersBox(container).querySelectorAll('button, p')].map( + (row) => row.textContent ?? '' + ) + expect(rows).toHaveLength(4) + expect(rows[0]).toContain('file-0.ts') + expect(rows[1]).toBe('between') + expect(rows[2]).toContain('file-1.ts') + expect(rows[3]).toBe('last') + }) + }) +} + +{ + function runHeader(container: HTMLElement): HTMLElement { + const header = container.querySelector('button') + if (!header) { + throw new Error('run header did not render') + } + return header + } + + describe('tool run sentences', () => { + afterEach(async () => { + cleanup() + await i18n.changeLanguage('en') + }) + it.each(['Task', 'Agent'])('separates failure counts in command and %s runs', (name) => { + const blocks: NativeChatBlock[] = [ + { type: 'tool-call', name: 'Bash', input: { command: 'false' }, state: 'failed' }, + { type: 'tool-result', output: 'exit 1', isError: true }, + { + type: 'tool-call', + name, + input: { description: 'explored settings search entries' }, + state: 'completed' + } + ] + const { container } = render() + const header = runHeader(container) + expect(header).toHaveTextContent('Ran 1 command and ran 1 agent · 1 failed') + expect(header).toHaveAccessibleName(/Failed tool calls: 1/) + expect(header).not.toHaveAccessibleName(/·/) + fireEvent.click(header) + expect(screen.getByText('Subagent')).toBeInTheDocument() + expect(screen.getByTitle('explored settings search entries')).toBeInTheDocument() + }) + + it('uses the agent glyph for a lone Agent transcript call', () => { + const { container } = render( + + ) + expect(runHeader(container)).toHaveTextContent('Ran 1 agent') + expect(container.querySelectorAll('button .lucide-bot')).toHaveLength(2) + }) + + it('relabels an already-mounted run and its rows when the UI language changes', async () => { + const blocks: NativeChatBlock[] = [ + { type: 'tool-call', name: 'Bash', input: { command: 'false' }, state: 'failed' }, + { type: 'tool-result', output: 'exit 1', isError: true }, + { + type: 'tool-call', + name: 'Agent', + input: { description: 'explore settings' }, + state: 'completed' + } + ] + const { container } = render( + + ) + const header = runHeader(container) + expect(header).toHaveTextContent('Ran 1 command and ran 1 agent · 1 failed') + expect(screen.getByText('Subagent')).toBeInTheDocument() + expect(screen.getByText('Background workflow')).toBeInTheDocument() + expect(screen.getByText('Output: /tmp/result.txt')).toBeInTheDocument() + + await act(async () => { + await i18n.changeLanguage('es') + }) + + expect(header).toHaveTextContent('Se ejecutó 1 comando y se ejecutó 1 agente · 1 fallidas') + expect(header).toHaveAccessibleName(/Llamadas a herramientas fallidas: 1/) + expect(screen.getByText('Subagente')).toBeInTheDocument() + expect(screen.getByText('Flujo de trabajo en segundo plano')).toBeInTheDocument() + expect(screen.getByText('Salida: /tmp/result.txt')).toBeInTheDocument() + expect(screen.getByText('bloqueado · con error')).toBeInTheDocument() + }) + }) +} diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.ask-row.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.ask-row.test.tsx deleted file mode 100644 index 7da57927465..00000000000 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.ask-row.test.tsx +++ /dev/null @@ -1,239 +0,0 @@ -// @vitest-environment happy-dom - -import '@testing-library/jest-dom/vitest' - -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it } from 'vitest' -import type { NativeChatBlock } from '../../../../shared/native-chat-types' -import { NativeChatToolRun } from './NativeChatToolRun' -import { - NativeChatDisclosureContext, - useNativeChatDisclosures -} from './native-chat-disclosure-store' -import { openToolRunMembers } from './native-chat-tool-run-members-test-support' - -afterEach(cleanup) - -const QUESTION = 'What would you like me to do next in this repo?' -const ASK_INPUT = { questions: [{ question: QUESTION }] } - -function askBlocks(state: 'running' | 'completed'): NativeChatBlock[] { - return [{ type: 'tool-call', name: 'AskUserQuestion', input: ASK_INPUT, state }] -} - -/** Lays every element out wider than its box, as a long question is on its line. */ -function clipEveryLine(): () => void { - const originals = ['scrollWidth', 'clientWidth'].map( - (name) => [name, Object.getOwnPropertyDescriptor(HTMLElement.prototype, name)] as const - ) - Object.defineProperty(HTMLElement.prototype, 'scrollWidth', { - configurable: true, - get: () => 400 - }) - Object.defineProperty(HTMLElement.prototype, 'clientWidth', { - configurable: true, - get: () => 100 - }) - return () => { - for (const [name, original] of originals) { - if (original) { - Object.defineProperty(HTMLElement.prototype, name, original) - } else { - Reflect.deleteProperty(HTMLElement.prototype, name) - } - } - } -} - -function DisclosureHarness({ mounted }: { mounted: boolean }): React.JSX.Element { - const disclosures = useNativeChatDisclosures() - return ( - - {mounted ? ( - - ) : null} - - ) -} - -describe('NativeChatToolRun awaiting-input row', () => { - it('does not revive stale tool state after a turn stops', () => { - render( - - ) - expect(screen.queryByText('Awaiting user input:')).toBeNull() - expect(screen.getByText('Asked:')).toBeInTheDocument() - }) - - it('keeps a pending question visible alongside another active tool', () => { - render( - - ) - expect(screen.getByText('Awaiting user input:')).toBeInTheDocument() - expect(screen.getByText('Reading 1 file')).toBeInTheDocument() - expect(screen.getByText('Read a.ts')).toBeInTheDocument() - }) - - it('preserves errors from failed question calls', () => { - render( - - ) - openToolRunMembers() - expect(screen.queryByText('Awaiting user input:')).toBeNull() - expect(screen.queryByText('Asked:')).toBeNull() - expect(screen.getAllByText('Question rejected').length).toBeGreaterThan(0) - }) - it('replaces a running ask call with the awaiting row', () => { - const { container } = render( - - ) - - expect(screen.getByText('Awaiting user input:')).toHaveClass( - 'animate-pulse', - 'motion-reduce:animate-none' - ) - expect(screen.getByText(QUESTION)).toBeInTheDocument() - expect(container.querySelector('.lucide-message-square-more')).toBeInTheDocument() - // The raw call and its payload are exactly what this row exists to replace. - expect(screen.queryByText(/Running AskUserQuestion/)).toBeNull() - expect(screen.queryByText(/AskUserQuestion/)).toBeNull() - }) - - it('reports a settled ask without the pulse or a tool-count header', () => { - const { container } = render( - - ) - - expect(screen.getByText('Asked:')).not.toHaveClass('animate-pulse') - expect(screen.getByText(QUESTION)).toBeInTheDocument() - // A run that is only the ask has no work left to head, so it draws no header. - expect(container.querySelector('[data-native-chat-tool-run-state]')).toBeNull() - }) - - it('leaves a question that fits on its line as plain text', () => { - const { container } = render( - - ) - expect(screen.getByText(QUESTION)).toHaveClass('truncate') - expect(container.querySelector('button')).toBeNull() - }) - - it('opens a clipped question below the toggle and folds it back', () => { - const restore = clipEveryLine() - try { - render( - - ) - const toggle = screen.getByRole('button', { name: /Asked:/ }) - expect(toggle).toHaveAttribute('aria-expanded', 'false') - - fireEvent.click(toggle) - expect(toggle).toHaveAttribute('aria-expanded', 'true') - const full = screen.getByText(QUESTION) - expect(full).not.toHaveClass('truncate') - // Outside the button, so it selects like prose and a click in it keeps it open. - expect(full.closest('button')).toBeNull() - fireEvent.click(full) - expect(toggle).toHaveAttribute('aria-expanded', 'true') - - fireEvent.click(toggle) - expect(toggle).toHaveAttribute('aria-expanded', 'false') - expect(screen.getByText(QUESTION)).toHaveClass('truncate') - } finally { - restore() - } - }) - - it('keeps an opened question open when the windowed row remounts', () => { - const restore = clipEveryLine() - try { - const { rerender } = render() - fireEvent.click(screen.getByRole('button', { name: /Asked:/ })) - - rerender() - expect(screen.queryByText(QUESTION)).toBeNull() - rerender() - - const toggle = screen.getByRole('button', { name: /Asked:/ }) - expect(toggle).toHaveAttribute('aria-expanded', 'true') - expect(screen.getByText(QUESTION).closest('button')).toBeNull() - - // Folding it keeps the same control, so keyboard focus is not dropped. - toggle.focus() - fireEvent.click(toggle) - expect(document.activeElement).toBe(toggle) - expect(toggle).toHaveAttribute('aria-expanded', 'false') - } finally { - restore() - } - }) - - it('offers no expansion when the row names only a question count', () => { - const restore = clipEveryLine() - try { - const input = { questions: [{ question: 'First?' }, { question: 'Second?' }] } - render( - - ) - expect(screen.getByText('2 questions')).toBeInTheDocument() - expect(screen.queryByRole('button', { name: /Asked:/ })).toBeNull() - } finally { - restore() - } - }) - - it('counts only the work that ran in the header beside the ask', () => { - const blocks: NativeChatBlock[] = [ - { type: 'tool-call', name: 'Read', input: { file_path: 'a.ts' }, state: 'completed' }, - { type: 'tool-call', name: 'AskUserQuestion', input: ASK_INPUT, state: 'running' } - ] - - render() - - expect(screen.getByText('Awaiting user input:')).toBeInTheDocument() - // One call ran; being asked a question is not work to summarize. The agent - // is blocked on the reader, so the run reads settled, not in progress. - expect(screen.getByText('Read 1 file')).toBeInTheDocument() - expect(screen.queryByText('Reading 1 file')).toBeNull() - }) - - it('draws the row from the tool name when the payload names no question', () => { - render( - - ) - - expect(screen.getByText('Awaiting user input')).toBeInTheDocument() - expect(screen.queryByText(/request_user_input/)).toBeNull() - }) -}) diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.members.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.members.test.tsx deleted file mode 100644 index f119194d53d..00000000000 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.members.test.tsx +++ /dev/null @@ -1,173 +0,0 @@ -// @vitest-environment happy-dom - -import { cleanup, fireEvent, render } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { NativeChatBlock } from '../../../../shared/native-chat-types' -import { - NativeChatDisclosureContext, - useNativeChatDisclosures -} from './native-chat-disclosure-store' -import { NativeChatToolRun } from './NativeChatToolRun' -import { revealNativeChatToolRunMember } from './NativeChatToolRunMembers' - -afterEach(() => { - cleanup() - vi.restoreAllMocks() -}) - -const VIEWPORT = 200 - -function calls(count: number): NativeChatBlock[] { - return Array.from({ length: count }, (_, index) => ({ - type: 'tool-call' as const, - name: 'Read', - callId: `call-${index}`, - input: `{"file_path":"src/file-${index}.ts"}` - })) -} - -/** happy-dom lays nothing out, so the box's geometry is whatever the test says it is. */ -function layOut(contentHeight: { current: number }): void { - vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation( - () => contentHeight.current - ) - vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(VIEWPORT) -} - -function membersBox(container: HTMLElement): HTMLElement { - const box = container.querySelector('[data-native-chat-tool-run-members]') - if (!box) { - throw new Error('members box did not render') - } - return box -} - -/** A live run inside the transcript's disclosure store, which is what remembers its box. */ -function Transcript({ - mounted = true, - count = 3 -}: { - mounted?: boolean - count?: number -}): React.JSX.Element { - const disclosures = useNativeChatDisclosures() - return ( - - {mounted ? ( - - ) : null} - - ) -} - -describe('NativeChatToolRun members box', () => { - it('keeps a live run on its newest call until the reader scrolls away', () => { - const contentHeight = { current: 600 } - layOut(contentHeight) - const run = (count: number): React.JSX.Element => ( - - ) - const { container, rerender } = render(run(3)) - const box = membersBox(container) - expect(box.scrollTop).toBe(600) - - contentHeight.current = 900 - rerender(run(4)) - expect(box.scrollTop).toBe(900) - - box.scrollTop = 100 - fireEvent.scroll(box) - contentHeight.current = 1200 - rerender(run(5)) - expect(box.scrollTop).toBe(100) - - box.scrollTop = 1200 - VIEWPORT - fireEvent.scroll(box) - contentHeight.current = 1500 - rerender(run(6)) - expect(box.scrollTop).toBe(1500) - }) - - it('comes back where the reader left it after windowing unmounts the row', () => { - const contentHeight = { current: 900 } - layOut(contentHeight) - const { container, rerender } = render() - const box = membersBox(container) - box.scrollTop = 100 - fireEvent.scroll(box) - - rerender() - contentHeight.current = 1200 - rerender() - - // Neither back at the newest call nor following again: both were the reader's choice. - expect(membersBox(container).scrollTop).toBe(100) - }) - - it('forgets its place when the reader closes it', () => { - layOut({ current: 900 }) - const { container } = render() - const box = membersBox(container) - box.scrollTop = 100 - fireEvent.scroll(box) - - const header = container.querySelector('[data-native-chat-tool-run-state]')! - fireEvent.click(header) - fireEvent.click(header) - - // A live run the reader reopens shows what it is doing now. - expect(membersBox(container).scrollTop).toBe(900) - }) - - it('opens a settled run at its first call', () => { - layOut({ current: 600 }) - const { container } = render( - - ) - expect(membersBox(container).scrollTop).toBe(0) - }) - - it('brings a revealed member to the top of the box and reports the box', () => { - layOut({ current: 600 }) - const { container } = render( - - ) - const box = membersBox(container) - const member = box.querySelectorAll('button')[2]! - vi.spyOn(box, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 40, 300, VIEWPORT)) - vi.spyOn(member, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 340, 300, 26)) - - expect(revealNativeChatToolRunMember(member)).toBe(box) - expect(box.scrollTop).toBe(300) - }) -}) - -describe('NativeChatToolRun asides', () => { - it('draws the rows it is handed among its calls, in order', () => { - const blocks = calls(2) - const { container } = render( - between

]]]), - after: [

last

] - }} - expandSignal - activeTurnIsWorking={false} - /> - ) - const rows = [...membersBox(container).querySelectorAll('button, p')].map( - (row) => row.textContent ?? '' - ) - expect(rows).toHaveLength(4) - expect(rows[0]).toContain('file-0.ts') - expect(rows[1]).toBe('between') - expect(rows[2]).toContain('file-1.ts') - expect(rows[3]).toBe('last') - }) -}) diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.sentences.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.sentences.test.tsx deleted file mode 100644 index 086aca9b749..00000000000 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.sentences.test.tsx +++ /dev/null @@ -1,106 +0,0 @@ -// @vitest-environment happy-dom -import '@testing-library/jest-dom/vitest' -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it } from 'vitest' -import { i18n } from '@/i18n/i18n' -import type { NativeChatBlock } from '../../../../shared/native-chat-types' -import { NativeChatToolRun } from './NativeChatToolRun' - -afterEach(async () => { - cleanup() - await i18n.changeLanguage('en') -}) - -function runHeader(container: HTMLElement): HTMLElement { - const header = container.querySelector('button') - if (!header) { - throw new Error('run header did not render') - } - return header -} - -describe('tool run sentences', () => { - it.each(['Task', 'Agent'])('separates failure counts in command and %s runs', (name) => { - const blocks: NativeChatBlock[] = [ - { type: 'tool-call', name: 'Bash', input: { command: 'false' }, state: 'failed' }, - { type: 'tool-result', output: 'exit 1', isError: true }, - { - type: 'tool-call', - name, - input: { description: 'explored settings search entries' }, - state: 'completed' - } - ] - const { container } = render() - const header = runHeader(container) - expect(header).toHaveTextContent('Ran 1 command and ran 1 agent · 1 failed') - expect(header).toHaveAccessibleName(/Failed tool calls: 1/) - expect(header).not.toHaveAccessibleName(/·/) - fireEvent.click(header) - expect(screen.getByText('Subagent')).toBeInTheDocument() - expect(screen.getByTitle('explored settings search entries')).toBeInTheDocument() - }) - - it('uses the agent glyph for a lone Agent transcript call', () => { - const { container } = render( - - ) - expect(runHeader(container)).toHaveTextContent('Ran 1 agent') - expect(container.querySelectorAll('button .lucide-bot')).toHaveLength(2) - }) - - it('relabels an already-mounted run and its rows when the UI language changes', async () => { - const blocks: NativeChatBlock[] = [ - { type: 'tool-call', name: 'Bash', input: { command: 'false' }, state: 'failed' }, - { type: 'tool-result', output: 'exit 1', isError: true }, - { - type: 'tool-call', - name: 'Agent', - input: { description: 'explore settings' }, - state: 'completed' - } - ] - const { container } = render( - - ) - const header = runHeader(container) - expect(header).toHaveTextContent('Ran 1 command and ran 1 agent · 1 failed') - expect(screen.getByText('Subagent')).toBeInTheDocument() - expect(screen.getByText('Background workflow')).toBeInTheDocument() - expect(screen.getByText('Output: /tmp/result.txt')).toBeInTheDocument() - - await act(async () => { - await i18n.changeLanguage('es') - }) - - expect(header).toHaveTextContent('Se ejecutó 1 comando y se ejecutó 1 agente · 1 fallidas') - expect(header).toHaveAccessibleName(/Llamadas a herramientas fallidas: 1/) - expect(screen.getByText('Subagente')).toBeInTheDocument() - expect(screen.getByText('Flujo de trabajo en segundo plano')).toBeInTheDocument() - expect(screen.getByText('Salida: /tmp/result.txt')).toBeInTheDocument() - expect(screen.getByText('bloqueado · con error')).toBeInTheDocument() - }) -}) diff --git a/src/renderer/src/i18n/integration-card-status-localization.test.ts b/src/renderer/src/i18n/integration-card-status-localization.test.ts deleted file mode 100644 index 739d4ed5edf..00000000000 --- a/src/renderer/src/i18n/integration-card-status-localization.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Integration cards build their status pill inside the JSX call, which the - * coverage audit does not inspect — it looks at JSX attributes and object - * properties, not conditional expressions passed to a prop. These assertions - * pin the catalog contract so a card that goes back to a bare literal fails here. - */ -import { describe, expect, it } from 'vitest' -import en from './locales/en.json' - -function lookup(key: string): string | undefined { - const value = key - .split('.') - .reduce( - (node, part) => - node && typeof node === 'object' ? (node as Record)[part] : undefined, - en as unknown - ) - return typeof value === 'string' ? value : undefined -} - -const REQUIRED_KEYS: Record = { - // Linear and other task trackers - 'auto.components.settings.task.tracker.integration.cards.statusConnected': 'Connected', - 'auto.components.settings.task.tracker.integration.cards.statusNotConnected': 'Not connected', - // Jira - 'auto.components.settings.jira.integration.card.statusConnected': 'Connected', - 'auto.components.settings.jira.integration.card.statusNotConnected': 'Not connected', - // Bitbucket, Azure DevOps, Gitea - 'auto.components.settings.token.source.control.integration.cards.statusConnected': 'Connected', - 'auto.components.settings.token.source.control.integration.cards.statusConfigured': 'Configured', - 'auto.components.settings.token.source.control.integration.cards.statusUnavailable': - 'Unavailable', - 'auto.components.settings.token.source.control.integration.cards.statusNotConfigured': - 'Not configured', - 'auto.components.settings.token.source.control.integration.cards.statusOptionalSetup': - 'Optional setup', - 'auto.components.settings.token.source.control.integration.cards.statusAuthFailed': 'Auth failed' -} - -describe('integration card status labels', () => { - it.each(Object.entries(REQUIRED_KEYS))('keeps %s in the English catalog', (key, expected) => { - expect(lookup(key)).toBe(expected) - }) -}) diff --git a/src/renderer/src/i18n/notification-agent-status-locales.test.ts b/src/renderer/src/i18n/notification-agent-status-locales.test.ts deleted file mode 100644 index 47828fa580a..00000000000 --- a/src/renderer/src/i18n/notification-agent-status-locales.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from 'vitest' -import en from './locales/en.json' -import es from './locales/es.json' -import fr from './locales/fr.json' -import ja from './locales/ja.json' -import ko from './locales/ko.json' -import zh from './locales/zh.json' - -// Main words a notification from these keys with an English fallback, so a locale missing one -// silently notifies in English; no other gate catches it. -describe('notification agent status locales', () => { - it.each(Object.entries({ en, es, fr, ja, ko, zh }))( - '%s words every verdict', - (_locale, catalog) => { - const words = catalog.notifications.agentStatus - for (const key of ['needsInput', 'working', 'stopped', 'failed', 'finished'] as const) { - expect(words[key]).toEqual(expect.any(String)) - expect(words[key].length).toBeGreaterThan(0) - } - } - ) -}) diff --git a/src/renderer/src/i18n/smart-workspace-jira-locales.test.ts b/src/renderer/src/i18n/smart-workspace-jira-locales.test.ts deleted file mode 100644 index 5e9112170e3..00000000000 --- a/src/renderer/src/i18n/smart-workspace-jira-locales.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from 'vitest' -import en from './locales/en.json' -import es from './locales/es.json' -import ja from './locales/ja.json' -import ko from './locales/ko.json' -import zh from './locales/zh.json' - -const localizedCatalogs = { es, ja, ko, zh } -const english = en.auto.components.new.workspace.SmartWorkspaceNameField -const recoveryKeys = [ - 'loadingJira', - 'jiraDisconnected', - 'jiraSiteNotConnected', - 'jiraRuntimeUpdate', - 'jiraReadFailed', - 'chooseJiraAccount', - 'jiraLoaded', - 'openSettings', - 'retryJira' -] as const - -describe('smart workspace Jira locale copy', () => { - it.each(Object.entries(localizedCatalogs))( - '%s localizes every Jira status and recovery string', - (_code, catalog) => { - const localized = catalog.auto.components.new.workspace.SmartWorkspaceNameField - for (const key of recoveryKeys) { - expect(localized[key].trim()).not.toBe('') - expect(localized[key]).not.toBe(english[key]) - } - } - ) -}) diff --git a/src/renderer/src/i18n/worktree-visibility-locales.test.ts b/src/renderer/src/i18n/worktree-visibility-locales.test.ts deleted file mode 100644 index d24d7b28865..00000000000 --- a/src/renderer/src/i18n/worktree-visibility-locales.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'vitest' -import en from './locales/en.json' -import es from './locales/es.json' - -describe('worktree visibility locales', () => { - it('uses number-neutral Spanish source counts', () => { - const found = es.auto.components.sidebar.WorktreeVisibilitySourceList.found - - expect(found.replace('{{value0}}', '1')).toBe('Encontrados: 1') - expect(found.replace('{{value0}}', '2')).toBe('Encontrados: 2') - }) - - it('keeps current fallback-derived keys in the English catalog', () => { - expect(en.auto.components.sidebar.NewExternalWorktreesInboxLine).toMatchObject({ - '6c07f3a91e': '{{value0}} on {{value1}}' - }) - expect(en.auto.components.sidebar.WorktreeVisibilityHelpPopover).toMatchObject({ - ec1e6a10fb: 'Other worktrees start hidden to avoid unexpected sidebar clutter.', - '1c68c9cf77': - 'Enable a source for all current and future worktrees, or show individual worktrees below.' - }) - expect(en.auto.components.sidebar.HiddenWorktreeRecoveryList).toMatchObject({ - '64e6f53f05': 'Show one without enabling its source.' - }) - }) -}) diff --git a/src/shared/skills-cli-agent-keys.test.ts b/src/shared/skills-cli-agent-keys.test.ts index 02bdd8b52df..1d2a2b32768 100644 --- a/src/shared/skills-cli-agent-keys.test.ts +++ b/src/shared/skills-cli-agent-keys.test.ts @@ -1,120 +1,7 @@ import { describe, expect, it } from 'vitest' -import { TUI_AGENT_CONFIG } from './tui-agent-config' -import { - SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT, - isUsableSkillsCliAgentKey, - SKILLS_CLI_UNIVERSAL_AGENT_KEY, - toSkillsCliAgentKeys -} from './skills-cli-agent-keys' - -// Why: the community `skills` CLI validates --agent against this namespace and -// exits 1 on anything else, so a typo here breaks installs outright. Captured -// from `skills add --agent `, which prints its own valid list (v1.5.20). -const SKILLS_CLI_VALID_AGENT_KEYS = new Set([ - 'aider-desk', - 'amp', - 'antigravity', - 'antigravity-cli', - 'astrbot', - 'autohand-code', - 'augment', - 'bob', - 'claude-code', - 'openclaw', - 'cline', - 'codearts-agent', - 'codebuddy', - 'codemaker', - 'codestudio', - 'codex', - 'command-code', - 'continue', - 'cortex', - 'crush', - 'cursor', - 'deepagents', - 'devin', - 'dexto', - 'droid', - 'eve', - 'firebender', - 'forgecode', - 'gemini-cli', - 'github-copilot', - 'goose', - 'grok', - 'hermes-agent', - 'inference-sh', - 'jazz', - 'junie', - 'iflow-cli', - 'kilo', - 'kimchi', - 'kimi-code-cli', - 'kiro-cli', - 'kode', - 'lingma', - 'loaf', - 'mcpjam', - 'mistral-vibe', - 'moxby', - 'mux', - 'opencode', - 'openhands', - 'ona', - 'pi', - 'qoder', - 'qoder-cn', - 'qwen-code', - 'replit', - 'reasonix', - 'rovodev', - 'roo', - 'tabnine-cli', - 'terramind', - 'tinycloud', - 'trae', - 'trae-cn', - 'warp', - 'windsurf', - 'zed', - 'zcode', - 'zencoder', - 'zenflow', - 'neovate', - 'pochi', - 'promptscript', - 'adal', - 'universal' -]) +import { isUsableSkillsCliAgentKey, toSkillsCliAgentKeys } from './skills-cli-agent-keys' describe('skills CLI agent keys', () => { - it('only maps onto keys the skills CLI accepts', () => { - for (const [agent, key] of Object.entries(SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT)) { - if (key !== null) { - expect(SKILLS_CLI_VALID_AGENT_KEYS, `${agent} -> ${key}`).toContain(key) - } - } - expect(SKILLS_CLI_VALID_AGENT_KEYS).toContain(SKILLS_CLI_UNIVERSAL_AGENT_KEY) - }) - - it('covers every agent Orca can detect', () => { - // Why: a new TuiAgent must be considered here, even if the answer is null — - // otherwise it silently falls back to universal-only with no decision made. - expect(Object.keys(SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT).sort()).toEqual( - Object.keys(TUI_AGENT_CONFIG).sort() - ) - }) - - it("follows Orca's own evidence for the two non-obvious mappings", () => { - // Why: src/shared/native-chat-agent-profiles.ts states OpenClaude reads - // Claude-owned roots, so it is not unmappable. - expect(SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT.openclaude).toBe('claude-code') - // Why: Orca detects trae via `traecli`, which tui-agent-config calls an alias - // only TRAE CN ships, so the CN directory is the right target. - expect(SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT.trae).toBe('trae-cn') - }) - it('rejects values the skills CLI would drop, and allows the explicit wildcard', () => { for (const bad of ['-y', '--copy', '', ' ', 'a b', 'a,b']) { expect(isUsableSkillsCliAgentKey(bad), bad).toBe(false) diff --git a/tests/e2e/alternate-screen-fixture-script.unit.test.ts b/tests/e2e/alternate-screen-fixture-script.unit.test.ts index 56718af3620..f7a0d6e2cec 100644 --- a/tests/e2e/alternate-screen-fixture-script.unit.test.ts +++ b/tests/e2e/alternate-screen-fixture-script.unit.test.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' import { HOLD_ALTERNATE_SCREEN_OPEN, @@ -54,38 +53,3 @@ describe('hidden pressure fixture', () => { ) }) }) - -// Ratchet: the builder is only worth anything while the specs still route through it, -// and a fixture hand-rolled back to paint-and-exit would surface days later in a -// scheduled Electron run — the detection channel that produced this ticket. -describe('spec alternate-screen fixtures', () => { - const SPEC_FIXTURE_BUILDERS: [spec: string, builder: string][] = [ - ['terminal-hidden-view-parking.spec.ts', 'writeParkedFrameScript'], - ['terminal-hidden-view-parking.spec.ts', 'writeCycleReferenceScript'], - ['terminal-hidden-tui-visual-restore.spec.ts', 'writeHiddenFrameScript'] - ] - - function readSpec(spec: string): string { - return readFileSync(new URL(spec, import.meta.url), 'utf8') - } - - function topLevelFunctionBody(source: string, name: string): string { - const start = source.indexOf(`function ${name}(`) - expect(start, `${name} was renamed or removed; re-point this ratchet`).toBeGreaterThan(-1) - // Why '\n}' and a bound: '\n}\n' misses CRLF checkouts, and an unresolved indexOf slices to EOF, - // letting a later builder call in the same file satisfy the assertion vacuously. - const end = source.indexOf('\n}', start) - expect(end, `${name} has no closing brace; re-point this ratchet`).toBeGreaterThan(start) - return source.slice(start, end) - } - - it.each(SPEC_FIXTURE_BUILDERS)('%s writes %s through the shared builder', (spec, builder) => { - expect(topLevelFunctionBody(readSpec(spec), builder)).toContain('alternateScreenFixtureScript(') - }) - - it('the OSC 8 spec stages its link fixture through the shared builder', () => { - expect(readSpec('terminal-osc8-cold-park-restore.spec.ts')).toContain( - 'stageNodeScriptForTerminal(alternateScreenFixtureScript(' - ) - }) -})